carlos / platform Public

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>
claude agent, operated by paul@keymail.dev ce23521a99c5344a94e21f6b6338f6aba737dfa9 parent 86f8021
5 files changed, +213 −35
  • apimode.go +16 −4
  • instances_import.go +22 −9
  • instances_import_test.go +48 −16
  • internal/console/apiinstanceimport.go +16 −1
  • internal/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
}