import: every mutation names its seed (final fix F1)
A delayed commit or abort of an import could land on a later import of the same host: the console guarded each transition on the seed id it had just read, not on the one the caller meant. The upload, stopped, commit and abort now carry ?seed=<id>, and the import gate refuses a request whose id is not the record's with SD-N60 before any transition; the transitions stay guarded on that id. The field is required: a request naming no seed is refused the same way. carlos instances import names the id begin answered; the standalone commit and abort verbs read the record first and name the id they read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+213
−35
apimode.go+16 −4instances_import.go+22 −9instances_import_test.go+48 −16internal/console/apiinstanceimport.go+16 −1internal/console/apiinstanceimport_test.go+111 −5
diff --git a/apimode.go b/apimode.go| index 1c3e99884..28bf2ca1a 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -3762,15 +3762,27 @@ func (s *apiSession) importBegin(ctx context.Context, a apiAcct, app, host, envi |
| // up to 60 minutes and answers 202 straight after the last byte. |
| const importUploadDeadline = 65 * time.Minute |
| +// importDoor is the path of host's import door, or of its verb, naming |
| +// seed id: the console acts only on the import the caller means (final |
| +// review F1), so a delayed request cannot land on a later import of the |
| +// same host. |
| +func importDoor(host, verb, id string) string { |
| + p := "/instances/" + url.PathEscape(host) + "/import" |
| + if verb != "" { |
| + p += "/" + verb |
| + } |
| + return p + "?seed=" + url.QueryEscape(id) |
| +} |
| + |
| // importUpload streams the snapshot (spec §5.2) on doStream's client, which |
| // has no overall timeout, under importUploadDeadline. Expect: 100-continue |
| // lets a refusal (busy scratch, wrong state) arrive before gigabytes are |
| // sent. A 202 means the console checks and stores it on its own; the |
| // caller follows the record to "uploaded". |
| -func (s *apiSession) importUpload(ctx context.Context, a apiAcct, app, host string, f *os.File, size int64) error { |
| +func (s *apiSession) importUpload(ctx context.Context, a apiAcct, app, host, id string, f *os.File, size int64) error { |
| ctx, cancel := context.WithTimeout(ctx, importUploadDeadline) |
| defer cancel() |
| - res, err := s.c.doStream(ctx, http.MethodPut, apiPath(a, app, "/instances/"+url.PathEscape(host)+"/import"), f, size, |
| + res, err := s.c.doStream(ctx, http.MethodPut, apiPath(a, app, importDoor(host, "", id)), f, size, |
| http.Header{"Content-Type": {"application/octet-stream"}, "Expect": {"100-continue"}}) |
| if err != nil { |
| return err |
| @@ -3785,8 +3797,8 @@ func (s *apiSession) importUpload(ctx context.Context, a apiAcct, app, host stri |
| // importVerb is the import's stopped, commit or abort (spec §5.3, §5.4, |
| // §5.6): a bodiless POST that answers 200. |
| -func (s *apiSession) importVerb(ctx context.Context, a apiAcct, app, host, verb string) error { |
| - status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)+"/import/"+verb), struct{}{}) |
| +func (s *apiSession) importVerb(ctx context.Context, a apiAcct, app, host, id, verb string) error { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, importDoor(host, verb, id)), struct{}{}) |
| if err != nil { |
| return err |
| } |
diff --git a/instances_import.go b/instances_import.go| index f5573189d..7ffe0c923 100644 |
| --- a/instances_import.go |
| +++ b/instances_import.go |
| @@ -190,7 +190,7 @@ func (s *apiSession) driveImport(ctx context.Context, w io.Writer, j importJob, |
| if _, err := f.Seek(0, io.SeekStart); err != nil { |
| return err |
| } |
| - if err := s.importUpload(ctx, j.acct, j.app, j.host, f, size); err != nil { |
| + if err := s.importUpload(ctx, j.acct, j.app, j.host, id, f, size); err != nil { |
| return err |
| } |
| // The console answered 202 and checks the file on its own; the record |
| @@ -287,14 +287,15 @@ var importLanded = map[string][]string{ |
| "abort": {"aborted", "rolled-back"}, |
| } |
| -// importStep runs one of the import's verbs. When the console refuses it, |
| -// the record says whether it landed anyway: a stopped whose first answer |
| -// was lost is refused naming a later state (SD-N54), and a commit or abort |
| -// that overlapped another is refused as changed (SD-N60). id, when known, |
| -// is this import's seed; an abort whose record has then gone is done, since |
| -// the abort finishes a rolled-back import and deletes its record. |
| +// importStep runs one of the import's verbs on seed id, which the console |
| +// requires (final review F1). When the console refuses it, the record says |
| +// whether it landed anyway: a stopped whose first answer was lost is |
| +// refused naming a later state (SD-N54), and a commit or abort that |
| +// overlapped another is refused as changed (SD-N60). An abort whose record |
| +// has then gone is done, since the abort finishes a rolled-back import and |
| +// deletes its record. |
| func (s *apiSession) importStep(ctx context.Context, a apiAcct, app, host, id, verb string) error { |
| - err := s.importVerb(ctx, a, app, host, verb) |
| + err := s.importVerb(ctx, a, app, host, id, verb) |
| if err == nil { |
| return nil |
| } |
| @@ -351,8 +352,20 @@ func runInstancesImportVerb(verb string, args []string) { |
| } |
| } |
| +// runImportVerb acts on the import the host holds now: it reads the record |
| +// and sends the seed id it read (final review F1), so a re-begun import is |
| +// only ever acted on by a command run after it began. A host with no record, |
| +// or no seed, sends no id, and the console's own answer is the error. |
| func (s *apiSession) runImportVerb(ctx context.Context, w io.Writer, a apiAcct, app, host, verb string) error { |
| - if err := s.importStep(ctx, a, app, host, "", verb); err != nil { |
| + rec, found, err := s.instanceGet(ctx, a, app, host) |
| + if err != nil { |
| + return err |
| + } |
| + id := "" |
| + if found && rec.Seed != nil { |
| + id = rec.Seed.ID |
| + } |
| + if err := s.importStep(ctx, a, app, host, id, verb); err != nil { |
| return err |
| } |
| if verb == "commit" { |
diff --git a/instances_import_test.go b/instances_import_test.go| index 8ecb61bd5..b230047d9 100644 |
| --- a/instances_import_test.go |
| +++ b/instances_import_test.go |
| @@ -153,7 +153,7 @@ func TestImportClientCallsTheDoor(t *testing.T) { |
| var seen []string |
| var begin map[string]any |
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| - seen = append(seen, r.Method+" "+r.URL.Path) |
| + seen = append(seen, r.Method+" "+r.URL.RequestURI()) |
| switch { |
| case r.Method == http.MethodPost && r.URL.Path == "/api/cli/apps/bab/shop/instances": |
| raw, _ := io.ReadAll(r.Body) |
| @@ -178,12 +178,13 @@ func TestImportClientCallsTheDoor(t *testing.T) { |
| t.Fatalf("begin body = %v", begin) |
| } |
| for _, verb := range []string{"stopped", "commit", "abort"} { |
| - if err := s.importVerb(context.Background(), a, "shop", "old.shop.example.com", verb); err != nil { |
| + if err := s.importVerb(context.Background(), a, "shop", "old.shop.example.com", "sd_imp", verb); err != nil { |
| t.Fatalf("%s: %v", verb, err) |
| } |
| } |
| - want := []string{"POST /api/cli/apps/bab/shop/instances", "POST /api/cli/apps/bab/shop/instances/old.shop.example.com/import/stopped", |
| - "POST /api/cli/apps/bab/shop/instances/old.shop.example.com/import/commit", "POST /api/cli/apps/bab/shop/instances/old.shop.example.com/import/abort"} |
| + // Every verb names the import it means (final review F1). |
| + want := []string{"POST /api/cli/apps/bab/shop/instances", "POST /api/cli/apps/bab/shop/instances/old.shop.example.com/import/stopped?seed=sd_imp", |
| + "POST /api/cli/apps/bab/shop/instances/old.shop.example.com/import/commit?seed=sd_imp", "POST /api/cli/apps/bab/shop/instances/old.shop.example.com/import/abort?seed=sd_imp"} |
| if len(seen) != len(want) { |
| t.Fatalf("calls = %q", seen) |
| } |
| @@ -253,13 +254,13 @@ func snapshotFile(t *testing.T, body string) *os.File { |
| func TestImportUploadStreamsTheFile(t *testing.T) { |
| var got struct { |
| - method, path, expect, ctype string |
| - length int64 |
| - body string |
| + method, path, seed, expect, ctype string |
| + length int64 |
| + body string |
| } |
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| raw, _ := io.ReadAll(r.Body) |
| - got.method, got.path, got.expect, got.ctype = r.Method, r.URL.Path, r.Header.Get("Expect"), r.Header.Get("Content-Type") |
| + got.method, got.path, got.seed, got.expect, got.ctype = r.Method, r.URL.Path, r.URL.Query().Get("seed"), r.Header.Get("Expect"), r.Header.Get("Content-Type") |
| got.length, got.body = r.ContentLength, string(raw) |
| time.Sleep(150 * time.Millisecond) |
| w.WriteHeader(http.StatusAccepted) |
| @@ -268,10 +269,10 @@ func TestImportUploadStreamsTheFile(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| s.c.httpc.Timeout = 20 * time.Millisecond // the upload never rides the JSON client |
| - if err := s.importUpload(context.Background(), apiAcct{Seg: "bab"}, "shop", "old.shop.example.com", snapshotFile(t, "abc"), 3); err != nil { |
| + if err := s.importUpload(context.Background(), apiAcct{Seg: "bab"}, "shop", "old.shop.example.com", "sd_imp", snapshotFile(t, "abc"), 3); err != nil { |
| t.Fatal(err) |
| } |
| - if got.method != http.MethodPut || got.path != "/api/cli/apps/bab/shop/instances/old.shop.example.com/import" || |
| + if got.method != http.MethodPut || got.path != "/api/cli/apps/bab/shop/instances/old.shop.example.com/import" || got.seed != "sd_imp" || |
| got.expect != "100-continue" || got.ctype != "application/octet-stream" || got.length != 3 || got.body != "abc" { |
| t.Fatalf("request = %+v", got) |
| } |
| @@ -285,7 +286,7 @@ func TestImportUploadRefusal(t *testing.T) { |
| fmt.Fprintf(w, `{"error":%q}`, sdN51) |
| })) |
| defer ts.Close() |
| - err := newTestSession(ts.URL).importUpload(context.Background(), apiAcct{Seg: "bab"}, "shop", "old.shop.example.com", snapshotFile(t, "abc"), 3) |
| + err := newTestSession(ts.URL).importUpload(context.Background(), apiAcct{Seg: "bab"}, "shop", "old.shop.example.com", "sd_imp", snapshotFile(t, "abc"), 3) |
| if err == nil || err.Error() != sdN51 { |
| t.Fatalf("err = %v", err) |
| } |
| @@ -304,6 +305,7 @@ type importConsole struct { |
| verbs map[string]func(w http.ResponseWriter) |
| gets []func(w http.ResponseWriter) |
| looks int |
| + seeds []string // the seed each upload and verb named, in order |
| } |
| const importBeginBody = `{"host":"main.shop.example.com","verify_token":"s3cret-token","seed":{"id":"sd_imp","kind":"import","state":"awaiting-upload"}}` |
| @@ -331,6 +333,7 @@ func (c *importConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| io.WriteString(w, c.beginBody) |
| case r.Method == http.MethodPut: |
| c.calls = append(c.calls, "upload") |
| + c.seeds = append(c.seeds, r.URL.Query().Get("seed")) |
| if c.upload != nil { |
| c.upload(w) |
| return |
| @@ -340,6 +343,7 @@ func (c *importConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| case r.Method == http.MethodPost: |
| verb := path[strings.LastIndex(path, "/")+1:] |
| c.calls = append(c.calls, verb) |
| + c.seeds = append(c.seeds, r.URL.Query().Get("seed")) |
| if f := c.verbs[verb]; f != nil { |
| f(w) |
| return |
| @@ -481,6 +485,23 @@ func TestImportWithVerifyCommits(t *testing.T) { |
| if !c.saw("commit") || c.saw("abort") { |
| t.Fatalf("calls = %v", c.called()) |
| } |
| + c.requireSeeds(t, "sd_imp", 3) // upload, stopped, commit |
| +} |
| + |
| +// requireSeeds: the console saw n uploads and verbs, and each named the |
| +// seed begin answered (final review F1). |
| +func (c *importConsole) requireSeeds(t *testing.T, id string, n int) { |
| + t.Helper() |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if len(c.seeds) != n { |
| + t.Fatalf("seeds named = %q, want %d of %q", c.seeds, n, id) |
| + } |
| + for _, got := range c.seeds { |
| + if got != id { |
| + t.Fatalf("seeds named = %q, want every one %q", c.seeds, id) |
| + } |
| + } |
| } |
| func TestImportWithVerifyAbortsOnAFailure(t *testing.T) { |
| @@ -494,6 +515,7 @@ func TestImportWithVerifyAbortsOnAFailure(t *testing.T) { |
| wantLines(t, strings.Join(lines[len(lines)-2:], "\n"), |
| "verify failed (500). Aborting the import.", |
| "aborted. The box puts the old route of main.shop.example.com back on its next pass.") |
| + c.requireSeeds(t, "sd_imp", 3) // upload, stopped, abort |
| if strings.Contains(out, "s3cret-token") || !c.saw("abort") || c.saw("commit") { |
| t.Fatalf("calls = %v, output:\n%s", c.called(), out) |
| } |
| @@ -681,22 +703,28 @@ func TestImportAbortOfAFinishedImport(t *testing.T) { |
| } |
| } |
| +// The verb alone acts on the import the host holds now: it reads the |
| +// record first and names that seed (final review F1). A host with no |
| +// record names none, and the console's answer is the error. |
| func TestImportVerbAlone(t *testing.T) { |
| for _, c := range []struct { |
| name, verb string |
| answer func(http.ResponseWriter) |
| record func(http.ResponseWriter) |
| + seed string // the seed the verb named |
| want string |
| wantErr string |
| }{ |
| - {"commit", "commit", nil, nil, "committed. main.shop.example.com is served by the platform now.", ""}, |
| - {"abort", "abort", nil, nil, "aborted. The box puts the old route of main.shop.example.com back on its next pass.", ""}, |
| + {"commit", "commit", nil, importRecord("placed", ""), "sd_imp", "committed. main.shop.example.com is served by the platform now.", ""}, |
| + {"abort", "abort", nil, importRecord("swapped", ""), "sd_imp", "aborted. The box puts the old route of main.shop.example.com back on its next pass.", ""}, |
| + {"abort of a re-begun import", "abort", nil, importRecordID("sd_other", "uploaded", ""), "sd_other", |
| + "aborted. The box puts the old route of main.shop.example.com back on its next pass.", ""}, |
| {"commit refused", "commit", answerWith(http.StatusConflict, `{"error":"main.shop.example.com is swapped. Commit it once it is placed."}`), |
| - importRecord("swapped", ""), "", "main.shop.example.com is swapped. Commit it once it is placed."}, |
| + importRecord("swapped", ""), "sd_imp", "", "main.shop.example.com is swapped. Commit it once it is placed."}, |
| {"abort that lost to its own retry", "abort", answerWith(http.StatusConflict, `{"error":"changed"}`), |
| - importRecord("aborted", ""), "aborted. The box puts the old route of main.shop.example.com back on its next pass.", ""}, |
| + importRecord("aborted", ""), "sd_imp", "aborted. The box puts the old route of main.shop.example.com back on its next pass.", ""}, |
| {"abort of a host with no record", "abort", answerWith(http.StatusNotFound, `{"error":"main.shop.example.com is not an instance of shop"}`), |
| - answerWith(http.StatusNotFound, ""), "", "main.shop.example.com is not an instance of shop"}, |
| + answerWith(http.StatusNotFound, ""), "", "", "main.shop.example.com is not an instance of shop"}, |
| } { |
| t.Run(c.name, func(t *testing.T) { |
| ic := newImportConsole(c.record) |
| @@ -707,6 +735,10 @@ func TestImportVerbAlone(t *testing.T) { |
| defer ts.Close() |
| var out bytes.Buffer |
| err := newTestSession(ts.URL).runImportVerb(context.Background(), &out, apiAcct{Seg: "bab"}, "shop", "main.shop.example.com", c.verb) |
| + if calls := ic.called(); len(calls) < 2 || calls[0] != "get" || calls[1] != c.verb { |
| + t.Fatalf("calls = %q, want the record read before the %s", calls, c.verb) |
| + } |
| + ic.requireSeeds(t, c.seed, 1) |
| if c.wantErr != "" { |
| if err == nil || err.Error() != c.wantErr || out.Len() != 0 { |
| t.Fatalf("err = %v, output %q", err, out.String()) |
diff --git a/internal/console/apiinstanceimport.go b/internal/console/apiinstanceimport.go| index b7d9a3b1c..b97b7d381 100644 |
| --- a/internal/console/apiinstanceimport.go |
| +++ b/internal/console/apiinstanceimport.go |
| @@ -267,7 +267,8 @@ const importUploadReadDeadline = 60 * time.Minute |
| // importGate is every import call's gate: the instance gate (cliRoute has |
| // already applied the instance role), the claim console's refusal, the |
| -// host, the record, and that the record is an import (SD-N59). |
| +// host, the record, that the record is an import (SD-N59), and that it is |
| +// the import the caller names (SD-N60). |
| func (s *Server) importGate(w http.ResponseWriter, r *http.Request) (principal string, acct *Account, app, host string, rec *provision.Record, ok bool) { |
| principal, acct, app, ok = s.instanceGate(w, r) |
| if !ok { |
| @@ -304,9 +305,23 @@ func (s *Server) importGate(w http.ResponseWriter, r *http.Request) (principal s |
| jsonError(w, ref.Status, ref.Msg) |
| return "", nil, "", "", nil, false |
| } |
| + // The caller names the import it means (final review F1). A delayed |
| + // stopped, commit, abort or upload sent for one import must never land |
| + // on a later import of the same host: the record's id is what the |
| + // transitions below are guarded on, so it has to be the caller's id |
| + // too. A request that names another seed, or none, changed nothing: |
| + // SD-N60, and the caller reads the state. |
| + if r.URL.Query().Get(importSeedParam) != rec.Seed.ID { |
| + s.importTransitionFailure(w, app, host, provision.ErrSeedConflict) |
| + return "", nil, "", "", nil, false |
| + } |
| return principal, acct, app, host, rec, true |
| } |
| +// importSeedParam is the query parameter every import mutation names its |
| +// seed id in: stopped, commit, abort and the upload. |
| +const importSeedParam = "seed" |
| + |
| // importUpload is an upload whose spool matched: everything |
| // runImportUpload needs once the request has been answered. It owns the |
| // spool's directory and the scratch reservation from the hand-over. |
diff --git a/internal/console/apiinstanceimport_test.go b/internal/console/apiinstanceimport_test.go| index bef67faef..237fc2cc1 100644 |
| --- a/internal/console/apiinstanceimport_test.go |
| +++ b/internal/console/apiinstanceimport_test.go |
| @@ -10,6 +10,7 @@ import ( |
| "errors" |
| "log" |
| "net/http" |
| + "net/url" |
| "os" |
| "path/filepath" |
| "strconv" |
| @@ -500,9 +501,35 @@ func uploadImport(t *testing.T, f fromFixture, host string, body []byte) (int, m |
| return code, out |
| } |
| -// putImport is uploadImport for a goroutine: it returns its error. |
| +// putImport is uploadImport for a goroutine: it returns its error. It |
| +// names the seed host's record holds as the request is made, as the |
| +// standalone verbs do (final review F1). |
| func putImport(f fromFixture, host string, body []byte) (int, map[string]any, error) { |
| - req, err := http.NewRequest(http.MethodPut, f.url+"/"+host+"/import", bytes.NewReader(body)) |
| + return putImportFor(f, host, heldSeed(f, host), body) |
| +} |
| + |
| +// heldSeed is the seed id host's record holds now, or "" with no record or |
| +// no seed. It never fails a test, so goroutines may call it. |
| +func heldSeed(f fromFixture, host string) string { |
| + rec, err := provision.GetRecord(context.Background(), f.fx.rel, "bab", fromApp, host) |
| + if err != nil || rec.Seed == nil { |
| + return "" |
| + } |
| + return rec.Seed.ID |
| +} |
| + |
| +// importDoorURL is host's import door (verb "" for the upload), naming id. |
| +func importDoorURL(f fromFixture, host, verb, id string) string { |
| + u := f.url + "/" + host + "/import" |
| + if verb != "" { |
| + u += "/" + verb |
| + } |
| + return u + "?seed=" + url.QueryEscape(id) |
| +} |
| + |
| +// putImportFor is putImport naming seed id. |
| +func putImportFor(f fromFixture, host, id string, body []byte) (int, map[string]any, error) { |
| + req, err := http.NewRequest(http.MethodPut, importDoorURL(f, host, "", id), bytes.NewReader(body)) |
| if err != nil { |
| return 0, nil, err |
| } |
| @@ -1037,10 +1064,17 @@ func TestImportUploadRefusals(t *testing.T) { |
| f.requireNothingUploaded(t) |
| } |
| -// postImport posts verb ("stopped" or "commit") to host's import door. |
| +// postImport posts verb ("stopped" or "commit") to host's import door, |
| +// naming the seed host's record holds now. |
| func postImport(t *testing.T, f fromFixture, host, verb string) (int, map[string]any) { |
| t.Helper() |
| - code, _, raw := rawReq(t, http.MethodPost, f.url+"/"+host+"/import/"+verb, f.token, "") |
| + return postImportFor(t, f, host, heldSeed(f, host), verb) |
| +} |
| + |
| +// postImportFor is postImport naming seed id. |
| +func postImportFor(t *testing.T, f fromFixture, host, id, verb string) (int, map[string]any) { |
| + t.Helper() |
| + code, _, raw := rawReq(t, http.MethodPost, importDoorURL(f, host, verb, id), f.token, "") |
| var out map[string]any |
| _ = json.Unmarshal([]byte(raw), &out) |
| return code, out |
| @@ -1403,6 +1437,78 @@ func TestImportOverlappingTransitions(t *testing.T) { |
| } |
| } |
| +// A delayed request of an import that has gone lands on nothing (final |
| +// review F1): import A is aborted and finished, import B begins on the same |
| +// host, and A's upload, stopped, commit and abort, arriving late, each |
| +// answer SD-N60 and leave B as it was. A request naming no seed is refused |
| +// the same way. B's own requests, and their idempotent repeats, answer as |
| +// before. |
| +func TestImportDelayedMutationsOfAnEarlierImport(t *testing.T) { |
| + f := newImportFixture(t) |
| + body, _ := sqliteBytes(t) |
| + a := f.begun(t, importHost, body).Seed.ID |
| + requireImportState(t, f.uploaded(t, importHost, body), a, seeds.StateUploaded, "") |
| + f.setSeed(t, provision.SeedAbort(a)) |
| + f.setSeed(t, provision.SeedRolledBack(a)) |
| + if code, out := postImportFor(t, f, importHost, a, "abort"); code != http.StatusOK || out["state"] != "rolled-back" { |
| + t.Fatalf("A's finishing abort = %d %v", code, out) |
| + } |
| + f.requireRecordGone(t, importHost) |
| + |
| + b := f.begun(t, importHost, body).Seed.ID |
| + if b == a { |
| + t.Fatal("B reused A's seed id") |
| + } |
| + stale := func(state string, send func(id string) (int, map[string]any)) { |
| + t.Helper() |
| + before := serial(t, f.fx) |
| + for _, id := range []string{a, ""} { |
| + if code, out := send(id); code != http.StatusConflict || str(t, out, "error") != importChanged(importHost) { |
| + t.Fatalf("a request naming seed %q while B is %s = %d %v, want SD-N60", id, state, code, out) |
| + } |
| + f.fx.srv.seedCopies.Wait() |
| + rec, _ := f.record(t, importHost) |
| + requireImportState(t, rec, b, state, "") |
| + } |
| + if serial(t, f.fx) != before { |
| + t.Fatalf("a stale request while B is %s bumped the serial", state) |
| + } |
| + } |
| + stale(seeds.StateAwaitingUpload, func(id string) (int, map[string]any) { |
| + code, out, err := putImportFor(f, importHost, id, body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return code, out |
| + }) |
| + f.requireNothingUploaded(t) |
| + requireImportState(t, f.uploaded(t, importHost, body), b, seeds.StateUploaded, "") |
| + stale(seeds.StateUploaded, func(id string) (int, map[string]any) { return postImportFor(t, f, importHost, id, "stopped") }) |
| + if code, out := postImportFor(t, f, importHost, b, "stopped"); code != http.StatusOK || out["state"] != "ready" { |
| + t.Fatalf("B's stopped = %d %v", code, out) |
| + } |
| + f.setSeed(t, provision.SeedSwapped(b)) |
| + f.setSeed(t, provision.SeedPlace(b)) |
| + for _, verb := range []string{"commit", "abort"} { |
| + stale(seeds.StatePlaced, func(id string) (int, map[string]any) { return postImportFor(t, f, importHost, id, verb) }) |
| + } |
| + if rows := importAudit(t, f, "import-committed"); len(rows) != 0 { |
| + t.Fatalf("a stale commit audited: %q", rows) |
| + } |
| + for range 2 { // B's commit, then its retry |
| + if code, out := postImportFor(t, f, importHost, b, "commit"); code != http.StatusOK || out["state"] != "committed" { |
| + t.Fatalf("B's commit = %d %v", code, out) |
| + } |
| + } |
| + if rows := importAudit(t, f, "import-committed"); len(rows) != 1 { |
| + t.Fatalf("import-committed rows = %q, want B's one", rows) |
| + } |
| + // A's abort after B committed is still SD-N60, not B's SD-N56. |
| + if code, out := postImportFor(t, f, importHost, a, "abort"); code != http.StatusConflict || str(t, out, "error") != importChanged(importHost) { |
| + t.Fatalf("A's abort after B committed = %d %v", code, out) |
| + } |
| +} |
| + |
| // ---- Abort and the delete door (spec §5.6, preflight M8, M14, M15) ---- |
| const ( |
| @@ -1425,7 +1531,7 @@ func importAbortFirst(host string) string { |
| // postImportErr is postImport for a goroutine: it returns its error and |
| // never calls t.Fatal (preflight m9). |
| func postImportErr(f fromFixture, host, verb string) (int, map[string]any, error) { |
| - req, err := http.NewRequest(http.MethodPost, f.url+"/"+host+"/import/"+verb, nil) |
| + req, err := http.NewRequest(http.MethodPost, importDoorURL(f, host, verb, heldSeed(f, host)), nil) |
| if err != nil { |
| return 0, nil, err |
| } |