carlos / platform Public

console: the import's abort and the delete door's import rule (Plan 6 Task 5)

POST .../import/abort is provision.SeedAbort through the import gate: every
state before committed, refused while a run is open (SD-N58) in the same
CAS, refused after commit (SD-N56). It is safe to repeat: aborted answers
200, rolled-back is finished and answers 200, and a lost race re-reads once
and answers by what won (preflight M14).

The delete door refuses an uncommitted import (SD-N57) unless no box has
swapped it: awaiting-upload, uploaded and refused move straight to
rolled-back and are finished, as is rolled-back, then answered as the
ordinary delete is. It fails closed with main's own delete sentence on a
read error, a bad seed id or a failed finish (M15, M8), and refuses an
uncommitted import on a claim console. Wires SD-N56, 57, 58 and 66.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5e97dd06bc95c55f63738abb2527a2922927ade8 parent ce9704c
8 files changed, +748 −2
  • approved_seeded_copy_test.go +3 −2
  • internal/console/apiinstanceimport.go +173 −0
  • internal/console/apiinstanceimport_test.go +552 −0
  • internal/console/apiinstances.go +11 −0
  • internal/console/authcli.go +1 −0
  • internal/console/claim_invariant_fixtures_test.go +6 −0
  • internal/console/svcroutes_test.go +1 −0
  • internal/console/testdata/claim-routes.txt +1 −0
diff --git a/approved_seeded_copy_test.go b/approved_seeded_copy_test.go
index 7232336bf..0de3b1b31 100644
--- a/approved_seeded_copy_test.go
+++ b/approved_seeded_copy_test.go
@@ -36,13 +36,14 @@ var seededCopyIDs = func() []string {
// SD-N111 and SD-N112; Plan 4 Task 3 SD-N14 to SD-N19; Plan 5 Task 3 SD-N27 to SD-N29,
// Task 5 SD-N20 to SD-N26, SD-N30 to SD-N39 and SD-N41; Plan 5 Task 6 SD-N40, SD-N42 to SD-N47, SD-N83 to SD-N88 and SD-N114;
// Plan 6 Task 1 SD-N48 to SD-N50, SD-N62 and SD-N67; Plan 6 Task 2 SD-N51 to SD-N53, SD-N59, SD-N61 and SD-N63;
-// Plan 6 Task 3 SD-N54, SD-N55, SD-N60, SD-N64, SD-N65 and SD-N115.
+// Plan 6 Task 3 SD-N54, SD-N55, SD-N60, SD-N64, SD-N65 and SD-N115; Plan 6 Task 5 SD-N56 to SD-N58 and SD-N66.
var seededWiredIDs = []string{"SD-N1", "SD-N2", "SD-N3", "SD-N4", "SD-N5", "SD-N6", "SD-N7", "SD-N8", "SD-N113", "SD-N9", "SD-N10", "SD-N11", "SD-N12", "SD-N13", "SD-N111", "SD-N112", "SD-N14", "SD-N15", "SD-N16", "SD-N17", "SD-N18", "SD-N19", "SD-N27", "SD-N28", "SD-N29",
"SD-N20", "SD-N21", "SD-N22", "SD-N23", "SD-N24", "SD-N25", "SD-N26", "SD-N30", "SD-N31", "SD-N32", "SD-N33", "SD-N34", "SD-N35", "SD-N36", "SD-N37", "SD-N38", "SD-N39", "SD-N41",
"SD-N40", "SD-N42", "SD-N43", "SD-N44", "SD-N45", "SD-N46", "SD-N47", "SD-N114", "SD-N83", "SD-N84", "SD-N85", "SD-N86", "SD-N87", "SD-N88",
"SD-N48", "SD-N49", "SD-N50", "SD-N62", "SD-N67",
"SD-N51", "SD-N52", "SD-N53", "SD-N59", "SD-N61", "SD-N63",
- "SD-N54", "SD-N55", "SD-N60", "SD-N64", "SD-N65", "SD-N115"}
+ "SD-N54", "SD-N55", "SD-N60", "SD-N64", "SD-N65", "SD-N115",
+ "SD-N56", "SD-N57", "SD-N58", "SD-N66"}
func mustParseSeededRecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/internal/console/apiinstanceimport.go b/internal/console/apiinstanceimport.go
index 44e2d6936..f43e0f0b5 100644
--- a/internal/console/apiinstanceimport.go
+++ b/internal/console/apiinstanceimport.go
@@ -743,3 +743,176 @@ func pastStopped(state string) bool {
}
return false
}
+
+// importDeleteFailed is main's own retry sentence of the delete door
+// (apiinstances.go), answered unchanged when the delete door's import rule
+// cannot read the record or finish the import (preflight M15).
+const importDeleteFailed = "could not delete the instance — try again"
+
+// importFinishTimeout bounds a finish run inline for a request: the
+// store's teardown has its own storeTeardownTimeout, and the reads, the
+// snapshot delete and the record delete around it need a little more.
+const importFinishTimeout = storeTeardownTimeout + instanceStoreTimeout
+
+// committedRefusal is abort after commit (SD-N56).
+func committedRefusal(host string) string {
+ return fmt.Sprintf("%s is committed. Delete it with carlos instances delete instead.", host)
+}
+
+// finishImport finishes a rolled-back import inline for a request: the
+// request's values, never its cancellation, so a client that leaves cannot
+// cut a teardown midway (preflight M15).
+func (s *Server) finishImport(r *http.Request, sqid, app, host, id string) error {
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), importFinishTimeout)
+ defer cancel()
+ return s.finishSeed(ctx, sqid, app, host, id)
+}
+
+// handleAPIImportAbort abandons an import before it is committed (spec
+// §5.6): a record CAS to aborted, then the box rolls it back. The
+// transition is provision.SeedAbort, which allows every state before
+// committed and refuses while a run is open, in the same CAS (preflight
+// M14: one rule, in provision). The no-reason form keeps a refused
+// import's reason.
+//
+// It is safe to repeat. An import already aborted answers 200 aborted; one
+// the box has rolled back is finished here (its store, its snapshot, then
+// its record) and answers 200 rolled-back, even when the finish fails and
+// is left to the next abort or the sweep. An abort that loses its race
+// re-reads once and answers by what won.
+func (s *Server) handleAPIImportAbort(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, host, rec, ok := s.importGate(w, r)
+ if !ok {
+ return
+ }
+ id := rec.Seed.ID
+ if s.abortAnswered(w, r, acct.Sqid, app, host, rec.Seed) {
+ return
+ }
+ wctx, wcancel := context.WithTimeout(context.WithoutCancel(r.Context()), instanceStoreTimeout)
+ defer wcancel()
+ _, err := provision.SetRecordSeed(wctx, s.cfg.Releases, acct.Sqid, app, host, provision.SeedAbort(id))
+ switch {
+ case errors.Is(err, provision.ErrRunOpen):
+ jsonError(w, http.StatusConflict, fmt.Sprintf("carlos run is busy on %s. Wait for it to finish, then abort.", host))
+ return
+ case lostTransition(err):
+ // Another request got there first. Answer as a sequential abort
+ // would be answered after it; anything but this import is SD-N60.
+ if now := s.importNow(wctx, acct.Sqid, app, host, id); now != nil && s.abortAnswered(w, r, acct.Sqid, app, host, now) {
+ return
+ }
+ s.importTransitionFailure(w, app, host, err)
+ return
+ case err != nil:
+ s.importTransitionFailure(w, app, host, err)
+ return
+ }
+ s.auditActivity(acct, app, principal, Activity{Action: "import-aborted", TargetType: "instance", Target: host,
+ Detail: fmt.Sprintf("aborted the import of %s", host)})
+ s.bumpSerial(wctx)
+ writeJSON(w, http.StatusOK, map[string]string{"state": seeds.StateAborted})
+}
+
+// abortAnswered answers an abort of seed that needs no transition, and
+// reports whether it did: committed is refused (SD-N56), aborted is done
+// already, and rolled-back is finished here. A finish that fails is
+// logged and still answers 200: the abort itself has landed, and the next
+// abort or the sweep finishes it.
+func (s *Server) abortAnswered(w http.ResponseWriter, r *http.Request, sqid, app, host string, seed *provision.SeedSource) bool {
+ switch seed.State {
+ case seeds.StateCommitted:
+ jsonError(w, http.StatusConflict, committedRefusal(host))
+ case seeds.StateAborted:
+ writeJSON(w, http.StatusOK, map[string]string{"state": seeds.StateAborted})
+ case seeds.StateRolledBack:
+ if err := s.finishImport(r, sqid, app, host, seed.ID); err != nil {
+ log.Printf("console: import %s: finish seed %s on abort: %v", host, seed.ID, err)
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"state": seeds.StateRolledBack})
+ default:
+ return false
+ }
+ return true
+}
+
+// refuseImportDelete is the delete door's import rule (spec §5.6, codex
+// R2-7), run before the ordinary delete. answered: the delete must not go
+// the ordinary way, and either this function has answered it (a refusal or
+// a failure), or, with finished, the import was finished here and the
+// caller writes the ordinary delete's audit row and answer.
+//
+// - awaiting-upload, uploaded, refused: no box has swapped, so the record
+// moves straight to rolled-back and is finished here (its store, its
+// snapshot under seeds/<host>/<id>/, then the record).
+// - rolled-back: finished here.
+// - any other uncommitted state: 409, abort the import instead (SD-N57).
+// - committed, a --from seed, an unseeded record, or no record at all:
+// (false, false), the ordinary delete.
+//
+// It fails closed (preflight M15): a record it cannot read, a seed id that
+// cannot name a key (M8) and a finish that fails answer main's delete
+// sentence, so a read blip never lets the ordinary delete drop an
+// uncommitted import's record and leak what its seed made. On a claim
+// console, where the finish runs nowhere, an uncommitted import is refused
+// as every import door refuses there.
+func (s *Server) refuseImportDelete(w http.ResponseWriter, r *http.Request, acct *Account, app, host string) (answered, finished bool) {
+ sqid := acct.Sqid
+ rec, err := s.readImportRecord(r.Context(), sqid, app, host)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, false
+ }
+ if err != nil {
+ log.Printf("console: api instances delete %s/%s/%s: read the record: %v", sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, importDeleteFailed)
+ return true, false
+ }
+ if !rec.Seed.Import() || rec.Seed.State == seeds.StateCommitted {
+ return false, false
+ }
+ if s.cfg.HostClaim {
+ ref := seedClaimConsoleRefusal()
+ jsonError(w, ref.Status, ref.Msg)
+ return true, false
+ }
+ id := rec.Seed.ID
+ if err := seeds.Check(host, id); err != nil {
+ log.Printf("console: api instances delete %s/%s/%s: import seed %q: %v", sqid, app, host, id, err)
+ jsonError(w, http.StatusBadGateway, importDeleteFailed)
+ return true, false
+ }
+ switch rec.Seed.State {
+ case seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateRefused:
+ wctx, wcancel := context.WithTimeout(context.WithoutCancel(r.Context()), instanceStoreTimeout)
+ _, err := provision.SetRecordSeed(wctx, s.cfg.Releases, sqid, app, host, provision.SeedRolledBackBeforeSwap(id))
+ var now *provision.SeedSource
+ if lostTransition(err) {
+ now = s.importNow(wctx, sqid, app, host, id)
+ }
+ wcancel()
+ switch {
+ case err == nil:
+ case errors.Is(err, os.ErrNotExist):
+ return false, false // gone already: the ordinary delete's idempotent answer
+ case now != nil && now.State == seeds.StateRolledBack:
+ // Another delete rolled it back first: finish it all the same.
+ case lostTransition(err):
+ s.importTransitionFailure(w, app, host, err) // SD-N60
+ return true, false
+ default:
+ log.Printf("console: api instances delete %s/%s/%s: roll back import seed %s: %v", sqid, app, host, id, err)
+ jsonError(w, http.StatusBadGateway, importDeleteFailed)
+ return true, false
+ }
+ case seeds.StateRolledBack:
+ default:
+ jsonError(w, http.StatusConflict, fmt.Sprintf("%s is an import that is not committed. Abort the import instead.", host))
+ return true, false
+ }
+ if err := s.finishImport(r, sqid, app, host, id); err != nil {
+ log.Printf("console: api instances delete %s/%s/%s: finish import seed %s: %v", sqid, app, host, id, err)
+ jsonError(w, http.StatusBadGateway, importDeleteFailed)
+ return true, false
+ }
+ return true, true
+}
diff --git a/internal/console/apiinstanceimport_test.go b/internal/console/apiinstanceimport_test.go
index c31b83727..62d9c4427 100644
--- a/internal/console/apiinstanceimport_test.go
+++ b/internal/console/apiinstanceimport_test.go
@@ -12,6 +12,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "strconv"
"strings"
"sync"
"sync/atomic"
@@ -1265,6 +1266,12 @@ func (s *movesOnWrite) Put(ctx context.Context, key string, body []byte, ifMatch
return s.Store.Put(ctx, key, body, ifMatch)
}
+// DeleteIfMatch forwards, so a finish behind the wrapper can delete the
+// record.
+func (s *movesOnWrite) DeleteIfMatch(ctx context.Context, key, etag string) error {
+ return release.DeleteIfMatch(ctx, s.Store, key, etag)
+}
+
// A stopped or a commit that read the import in one state and finds it in
// another when it writes changes nothing: SD-N60, no audit row and no bump.
// It never acts on an import that has moved on (its transition is guarded
@@ -1395,3 +1402,548 @@ func TestImportOverlappingTransitions(t *testing.T) {
})
}
}
+
+// ---- Abort and the delete door (spec §5.6, preflight M8, M14, M15) ----
+
+const (
+ mainDeleteFailed = "could not delete the instance — try again"
+ importRunBusy = "carlos run is busy on " + importHost + ". Wait for it to finish, then abort."
+)
+
+func importChanged(host string) string {
+ return host + " changed while this request was made. Read its state with carlos instances list and try again."
+}
+
+func importCommitted(host string) string {
+ return host + " is committed. Delete it with carlos instances delete instead."
+}
+
+func importAbortFirst(host string) string {
+ return host + " is an import that is not committed. Abort the import instead."
+}
+
+// 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)
+ if err != nil {
+ return 0, nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+f.token)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer res.Body.Close()
+ var out map[string]any
+ _ = json.NewDecoder(res.Body).Decode(&out)
+ return res.StatusCode, out, nil
+}
+
+// deleteInstance sends the ordinary delete for host and returns what it
+// answered.
+func deleteInstance(t *testing.T, f fromFixture, host string) (int, map[string]any) {
+ t.Helper()
+ code, _, raw := rawReq(t, http.MethodDelete, f.url+"/"+host, f.token, "")
+ var out map[string]any
+ _ = json.Unmarshal([]byte(raw), &out)
+ return code, out
+}
+
+// setRun writes the run series' field directly, through the public
+// etag-guarded writer: run admission refuses an uncommitted import, so a
+// run open on one is only reachable by a write like this (a run admitted
+// before the import began, in the spec's words).
+func setRun(t *testing.T, f fromFixture, host, state string) {
+ t.Helper()
+ rec, etag, err := provision.GetRecordETag(context.Background(), f.fx.rel, "bab", fromApp, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec.ActiveRun = &provision.RunState{ID: "run_1", State: state, Deadline: time.Now().Add(time.Hour)}
+ if err := provision.UpdateRecord(context.Background(), f.fx.rel, *rec, etag); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// A commit and an abort race on the record's etag, and exactly one wins
+// (spec §5.6). The loser is told why: an abort that lost to the commit is
+// SD-N56, a commit that lost to the abort is SD-N60 (or SD-N115 when the
+// abort landed before its read). Neither is a 502.
+func TestImportCommitAbortRace(t *testing.T) {
+ f := newImportFixture(t)
+ body, _ := sqliteBytes(t)
+ begun := f.begun(t, importHost, body)
+ requireImportState(t, f.uploaded(t, importHost, body), begun.Seed.ID, seeds.StateUploaded, "")
+ if code, out := postImport(t, f, importHost, "stopped"); code != http.StatusOK {
+ t.Fatalf("stopped = %d %v", code, out)
+ }
+ f.setSeed(t, provision.SeedSwapped(begun.Seed.ID))
+ f.setSeed(t, provision.SeedPlace(begun.Seed.ID))
+ hosts := []string{importHost}
+ for i := range 8 {
+ host := "race" + strconv.Itoa(i) + ".shop.example.com"
+ f.seedRecord(t, host, "", newSeedID(t), seeds.KindImport, seeds.StatePlaced)
+ hosts = append(hosts, host)
+ }
+ type answer struct {
+ verb string
+ code int
+ out map[string]any
+ err error
+ }
+ for _, host := range hosts {
+ answers := make(chan answer, 2)
+ for _, verb := range []string{"commit", "abort"} {
+ go func(verb string) {
+ code, out, err := postImportErr(f, host, verb)
+ answers <- answer{verb, code, out, err}
+ }(verb)
+ }
+ got := map[string]answer{}
+ for range 2 {
+ a := <-answers
+ if a.err != nil {
+ t.Fatalf("%s %s: %v", host, a.verb, a.err)
+ }
+ got[a.verb] = a
+ }
+ rec, err := f.record(t, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ commit, abort := got["commit"], got["abort"]
+ switch rec.Seed.State {
+ case seeds.StateCommitted:
+ if commit.code != http.StatusOK || abort.code != http.StatusConflict || abort.out["error"] != importCommitted(host) {
+ t.Fatalf("%s committed: commit %d %v, abort %d %v", host, commit.code, commit.out, abort.code, abort.out)
+ }
+ case seeds.StateAborted:
+ lost := commit.out["error"] == importChanged(host) || commit.out["error"] == host+" is aborted, so there is nothing to commit."
+ if abort.code != http.StatusOK || abort.out["state"] != seeds.StateAborted || commit.code != http.StatusConflict || !lost {
+ t.Fatalf("%s aborted: commit %d %v, abort %d %v", host, commit.code, commit.out, abort.code, abort.out)
+ }
+ default:
+ t.Fatalf("%s ended %s: commit %d %v, abort %d %v; want exactly one winner", host, rec.Seed.State, commit.code, commit.out, abort.code, abort.out)
+ }
+ }
+ committed, aborted := importAudit(t, f, "import-committed"), importAudit(t, f, "import-aborted")
+ if len(committed)+len(aborted) != len(hosts) {
+ t.Fatalf("audit rows: %d committed, %d aborted, want one per host (%d)", len(committed), len(aborted), len(hosts))
+ }
+}
+
+// Abort is refused while a run is open and after commit, lands from every
+// state before commit, and is safe to repeat.
+func TestImportAbortRefusals(t *testing.T) {
+ f := newImportFixture(t)
+ body, sha := sqliteBytes(t)
+ code, out := beginImport(t, f, importHost, sha, int64(len(body)))
+ begun, _ := f.requireBegun(t, code, out, importHost)
+ id := begun.Seed.ID
+
+ setRun(t, f, importHost, provision.RunPicked)
+ before := serial(t, f.fx)
+ if code, out := postImport(t, f, importHost, "abort"); code != http.StatusConflict || str(t, out, "error") != importRunBusy {
+ t.Fatalf("abort with a run open = %d %v", code, out)
+ }
+ rec, _ := f.record(t, importHost)
+ requireImportState(t, rec, id, seeds.StateAwaitingUpload, "")
+ if serial(t, f.fx) != before || len(importAudit(t, f, "import-aborted")) != 0 {
+ t.Fatal("a refused abort bumped the serial or wrote an audit row")
+ }
+
+ setRun(t, f, importHost, provision.RunDone)
+ if code, out := postImport(t, f, importHost, "abort"); code != http.StatusOK || len(out) != 1 || out["state"] != "aborted" {
+ t.Fatalf("abort = %d %v", code, out)
+ }
+ rec, _ = f.record(t, importHost)
+ requireImportState(t, rec, id, seeds.StateAborted, "")
+ if got := serial(t, f.fx); got != before+1 {
+ t.Fatalf("serial after abort = %d, want %d", got, before+1)
+ }
+ if rows := importAudit(t, f, "import-aborted"); len(rows) != 1 || rows[0] != "aborted the import of "+importHost {
+ t.Fatalf("import-aborted rows = %q", rows)
+ }
+
+ // Again: 200, and nothing written, audited or bumped.
+ before = serial(t, f.fx)
+ if code, out := postImport(t, f, importHost, "abort"); code != http.StatusOK || len(out) != 1 || out["state"] != "aborted" {
+ t.Fatalf("abort again = %d %v, want 200", code, out)
+ }
+ if serial(t, f.fx) != before || len(importAudit(t, f, "import-aborted")) != 1 {
+ t.Fatal("a repeated abort bumped the serial or wrote an audit row")
+ }
+
+ // After commit, abort is refused: delete is the verb then.
+ f.seedRecord(t, "done.shop.example.com", "", newSeedID(t), seeds.KindImport, seeds.StateCommitted)
+ if code, out := postImport(t, f, "done.shop.example.com", "abort"); code != http.StatusConflict ||
+ str(t, out, "error") != importCommitted("done.shop.example.com") {
+ t.Fatalf("abort after commit = %d %v", code, out)
+ }
+ if rec, _ := f.record(t, "done.shop.example.com"); rec.Seed.State != seeds.StateCommitted {
+ t.Fatalf("a refused abort moved a committed import to %s", rec.Seed.State)
+ }
+}
+
+// Every state before committed aborts. A refused import keeps the reason
+// the box gave (preflight M14: the no-reason SeedAbort).
+func TestImportAbortFromEveryStateBeforeCommit(t *testing.T) {
+ f := newImportFixture(t)
+ for _, state := range []string{seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateReady,
+ seeds.StateSwapped, seeds.StatePlaced, seeds.StateRefused} {
+ host := state + ".shop.example.com"
+ id := newSeedID(t)
+ f.seedRecord(t, host, "", id, seeds.KindImport, state)
+ reason := ""
+ if state == seeds.StateRefused {
+ reason = "the box found no addr row"
+ rec, etag, err := provision.GetRecordETag(context.Background(), f.fx.rel, "bab", fromApp, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec.Seed.Reason = reason
+ raw, _ := json.Marshal(rec)
+ if _, err := f.fx.rel.Put(context.Background(), provision.RecordKey("bab", fromApp, host), raw, etag); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if code, out := postImport(t, f, host, "abort"); code != http.StatusOK || out["state"] != "aborted" {
+ t.Errorf("abort while %s = %d %v", state, code, out)
+ continue
+ }
+ rec, _ := f.record(t, host)
+ requireImportState(t, rec, id, seeds.StateAborted, reason)
+ }
+}
+
+// A rolled-back import is finished by its abort: the record and the
+// snapshot go, and the answer says rolled-back. A finish that fails still
+// answers 200 (the abort has landed), and leaves the record rolled-back
+// for the next abort or the sweep.
+func TestImportAbortFinishesARolledBackImport(t *testing.T) {
+ f := newImportFixture(t)
+ body, _ := sqliteBytes(t)
+ begun := f.begun(t, importHost, body)
+ id := begun.Seed.ID
+ requireImportState(t, f.uploaded(t, importHost, body), id, seeds.StateUploaded, "")
+ f.setSeed(t, provision.SeedAbort(id))
+ f.setSeed(t, provision.SeedRolledBack(id))
+
+ data := f.fx.srv.cfg.SeedData
+ f.fx.srv.cfg.SeedData = nil // the finish cannot delete the snapshot
+ if code, out := postImport(t, f, importHost, "abort"); code != http.StatusOK || len(out) != 1 || out["state"] != "rolled-back" {
+ t.Fatalf("abort with a failing finish = %d %v", code, out)
+ }
+ rec, _ := f.record(t, importHost)
+ requireImportState(t, rec, id, seeds.StateRolledBack, "")
+
+ f.fx.srv.cfg.SeedData = data
+ if code, out := postImport(t, f, importHost, "abort"); code != http.StatusOK || len(out) != 1 || out["state"] != "rolled-back" {
+ t.Fatalf("abort of a rolled-back import = %d %v", code, out)
+ }
+ f.requireRecordGone(t, importHost)
+ f.requireNothingUploaded(t)
+ if rows := importAudit(t, f, "import-aborted"); len(rows) != 0 {
+ t.Fatalf("finishing a rolled-back import audited an abort: %q", rows)
+ }
+}
+
+// An abort that loses its race re-reads once and answers by what it finds
+// (preflight M14): another abort is a 200, a commit is SD-N56, a rollback
+// is finished and a 200, a run that opened is SD-N58, and anything else is
+// SD-N60. The loser writes no audit row and bumps nothing.
+func TestImportAbortLosesARace(t *testing.T) {
+ ctx := context.Background()
+ for _, c := range []struct {
+ name string
+ hook func(f fromFixture, id string) error
+ code int
+ state string // the 200's state, or "" for an error
+ msg string
+ after string // the record's state after, "" for gone
+ }{
+ {"to another abort", func(f fromFixture, id string) error {
+ _, err := provision.SetRecordSeed(ctx, f.fx.rel, "bab", fromApp, importHost, provision.SeedAbort(id))
+ return err
+ }, http.StatusOK, seeds.StateAborted, "", seeds.StateAborted},
+ {"to a commit", func(f fromFixture, id string) error {
+ _, err := provision.SetRecordSeed(ctx, f.fx.rel, "bab", fromApp, importHost, provision.SeedCommit(id))
+ return err
+ }, http.StatusConflict, "", importCommitted(importHost), seeds.StateCommitted},
+ {"to a rollback", func(f fromFixture, id string) error {
+ for _, step := range []func(string) func(*provision.Record) error{provision.SeedAbort, provision.SeedRolledBack} {
+ if _, err := provision.SetRecordSeed(ctx, f.fx.rel, "bab", fromApp, importHost, step(id)); err != nil {
+ return err
+ }
+ }
+ return nil
+ }, http.StatusOK, seeds.StateRolledBack, "", ""},
+ {"to a run", func(f fromFixture, id string) error {
+ rec, etag, err := provision.GetRecordETag(ctx, f.fx.rel, "bab", fromApp, importHost)
+ if err != nil {
+ return err
+ }
+ rec.ActiveRun = &provision.RunState{ID: "run_1", State: provision.RunQueued, Deadline: time.Now().Add(time.Hour)}
+ return provision.UpdateRecord(ctx, f.fx.rel, *rec, etag)
+ }, http.StatusConflict, "", importRunBusy, seeds.StatePlaced},
+ {"to another import of the host", func(f fromFixture, id string) error {
+ if err := f.fx.rel.Delete(ctx, provision.RecordKey("bab", fromApp, importHost)); err != nil {
+ return err
+ }
+ other, err := seeds.NewID()
+ if err != nil {
+ return err
+ }
+ rec := provision.Record{Schema: 1, Host: importHost, Slug: "old", App: fromApp, Account: "bab", Channel: "stable", CreatedAt: time.Now().UTC(),
+ Seed: &provision.SeedSource{ID: other, Kind: seeds.KindImport, State: seeds.StateAborted, At: time.Now().UTC()}}
+ return provision.CreateRecord(ctx, f.fx.rel, rec)
+ }, http.StatusConflict, "", importChanged(importHost), seeds.StateAborted},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ f := newImportFixture(t)
+ id := newSeedID(t)
+ f.seedRecord(t, importHost, "", id, seeds.KindImport, seeds.StatePlaced)
+ f.fx.srv.cfg.Releases = &movesOnWrite{Store: f.fx.rel, key: provision.RecordKey("bab", fromApp, importHost), hook: func() {
+ if err := c.hook(f, id); err != nil {
+ t.Error(err)
+ }
+ }}
+ before := serial(t, f.fx)
+ code, out := postImport(t, f, importHost, "abort")
+ if code != c.code || (c.state != "" && (len(out) != 1 || out["state"] != c.state)) || (c.msg != "" && str(t, out, "error") != c.msg) {
+ t.Fatalf("abort = %d %v, want %d %q %q", code, out, c.code, c.state, c.msg)
+ }
+ rec, err := f.record(t, importHost)
+ switch {
+ case c.after == "":
+ f.requireRecordGone(t, importHost)
+ case err != nil || rec.Seed == nil || rec.Seed.State != c.after:
+ t.Fatalf("record after = %+v, %v; want %s", rec, err, c.after)
+ }
+ if c.after != "" && serial(t, f.fx) != before {
+ t.Fatal("the losing abort bumped the serial")
+ }
+ if rows := importAudit(t, f, "import-aborted"); len(rows) != 0 {
+ t.Fatalf("the losing abort audited: %q", rows)
+ }
+ })
+ }
+}
+
+// Abort shares the import gate.
+func TestImportAbortGate(t *testing.T) {
+ f := newImportFixture(t)
+ f.seedRecord(t, fromHost, "", newSeedID(t), seeds.KindReplica, seeds.StateReady)
+ f.seedRecord(t, importHost, "", newSeedID(t), seeds.KindImport, seeds.StatePlaced)
+ for _, c := range []struct {
+ name, host string
+ code int
+ msg string
+ }{
+ {"no record", "ghost.shop.example.com", http.StatusNotFound, "ghost.shop.example.com is not an instance of shop"},
+ {"not a host", "Not_A_Host", http.StatusNotFound, "Not_A_Host is not an instance of shop"},
+ {"an ordinary instance", fromSource, http.StatusConflict, fromSource + " is not an import."},
+ {"a --from seed", fromHost, http.StatusConflict, fromHost + " is not an import."},
+ } {
+ if code, out := postImport(t, f, c.host, "abort"); code != c.code || str(t, out, "error") != c.msg {
+ t.Errorf("abort %s = %d %v, want %d %q", c.name, code, out, c.code, c.msg)
+ }
+ }
+ f.fx.srv.cfg.HostClaim = true
+ if code, out := postImport(t, f, importHost, "abort"); code != http.StatusConflict ||
+ str(t, out, "error") != seedClaimConsoleRefusal().Msg {
+ t.Errorf("abort with claims armed = %d %v", code, out)
+ }
+ f.fx.srv.cfg.HostClaim = false
+ if rec, _ := f.record(t, importHost); rec.Seed.State != seeds.StatePlaced {
+ t.Fatalf("a refused abort moved the import to %s", rec.Seed.State)
+ }
+}
+
+// The delete door's import rule (spec §5.6, codex R2-7): an import no box
+// has swapped is rolled back and finished by the delete, answered as the
+// ordinary delete is; a rolled-back one is finished; every other
+// uncommitted import is refused with SD-N57; a committed one is an
+// ordinary instance.
+func TestImportDeleteRefusedUntilCommittedOrUnswapped(t *testing.T) {
+ f := newImportFixture(t)
+ body, sha := sqliteBytes(t)
+ code, out := beginImport(t, f, "early.shop.example.com", sha, int64(len(body)))
+ f.requireBegun(t, code, out, "early.shop.example.com")
+ before := serial(t, f.fx)
+ if code, out := deleteInstance(t, f, "early.shop.example.com"); code != http.StatusOK || len(out) != 1 || out["result"] != "deleted" {
+ t.Fatalf("delete before upload = %d %v, want 200 deleted", code, out)
+ }
+ f.requireRecordGone(t, "early.shop.example.com")
+ if serial(t, f.fx) <= before {
+ t.Fatal("the delete did not bump the serial")
+ }
+ if rows := importAudit(t, f, "instance-delete"); len(rows) != 1 || rows[0] != "deleted instance early.shop.example.com (cli)" {
+ t.Fatalf("instance-delete rows = %q", rows)
+ }
+ // Again: the ordinary delete's idempotent 200.
+ if code, out := deleteInstance(t, f, "early.shop.example.com"); code != http.StatusOK || out["result"] != "deleted" {
+ t.Fatalf("second delete = %d %v", code, out)
+ }
+
+ // Uploaded: the snapshot goes too.
+ begun := f.begun(t, importHost, body)
+ requireImportState(t, f.uploaded(t, importHost, body), begun.Seed.ID, seeds.StateUploaded, "")
+ if keys, _ := f.data.List(context.Background(), seeds.Prefix); len(keys) != 1 {
+ t.Fatalf("seeds/ before the delete = %v, want the snapshot", keys)
+ }
+ if code, out := deleteInstance(t, f, importHost); code != http.StatusOK || out["result"] != "deleted" {
+ t.Fatalf("delete after upload = %d %v", code, out)
+ }
+ f.requireRecordGone(t, importHost)
+ f.requireNothingUploaded(t)
+
+ // Refused (no box swapped) and rolled-back are finished; aborted,
+ // ready, swapped and placed are the box's to roll back first.
+ for _, c := range []struct {
+ state string
+ gone bool
+ }{
+ {seeds.StateRefused, true},
+ {seeds.StateRolledBack, true},
+ {seeds.StateReady, false},
+ {seeds.StateSwapped, false},
+ {seeds.StatePlaced, false},
+ {seeds.StateAborted, false},
+ } {
+ host := c.state + ".shop.example.com"
+ f.seedRecord(t, host, "", newSeedID(t), seeds.KindImport, c.state)
+ code, out := deleteInstance(t, f, host)
+ if c.gone {
+ if code != http.StatusOK || out["result"] != "deleted" {
+ t.Errorf("delete while %s = %d %v, want 200", c.state, code, out)
+ }
+ f.requireRecordGone(t, host)
+ continue
+ }
+ if code != http.StatusConflict || str(t, out, "error") != importAbortFirst(host) {
+ t.Errorf("delete while %s = %d %v, want 409 %q", c.state, code, out, importAbortFirst(host))
+ }
+ if rec, err := f.record(t, host); err != nil || rec.Seed.State != c.state {
+ t.Errorf("a refused delete while %s left %+v, %v", c.state, rec, err)
+ }
+ }
+
+ // Committed: the ordinary delete.
+ f.seedRecord(t, "done.shop.example.com", "", newSeedID(t), seeds.KindImport, seeds.StateCommitted)
+ if code, out := deleteInstance(t, f, "done.shop.example.com"); code != http.StatusOK || out["result"] != "deleted" {
+ t.Fatalf("delete of a committed import = %d %v", code, out)
+ }
+ f.requireRecordGone(t, "done.shop.example.com")
+}
+
+// The pre-swap delete of a private-storage import tears down the empty
+// store its begin made (preflight m11).
+func TestImportDeleteTearsDownTheStore(t *testing.T) {
+ f := newImportFixture(t)
+ enableInstances(t, f.fx, fromApp, fromManifest(true))
+ code, out := beginImport(t, f, importHost, importSHA, 10)
+ rec, _ := f.requireBegun(t, code, out, importHost)
+ store, _, err := objstore.GetIn(context.Background(), f.fx.rel, "bab", fromApp, rec.Environment)
+ if err != nil || store == nil || store.Declared == nil || store.Declared.Seed != rec.Seed.ID {
+ t.Fatalf("store = %+v, %v; want one marked with seed %s", store, err, rec.Seed.ID)
+ }
+ bucket := storeBucketName(store)
+ if _, ok := f.fake.Buckets[bucket]; !ok {
+ t.Fatalf("bucket %s was never made", bucket)
+ }
+ if code, out := deleteInstance(t, f, importHost); code != http.StatusOK || out["result"] != "deleted" {
+ t.Fatalf("delete = %d %v", code, out)
+ }
+ f.requireRecordGone(t, importHost)
+ if store, _, err := objstore.GetIn(context.Background(), f.fx.rel, "bab", fromApp, rec.Environment); err != nil || store != nil {
+ t.Fatalf("store after the delete = %+v, %v; want it gone", store, err)
+ }
+ if _, ok := f.fake.Buckets[bucket]; ok {
+ t.Fatalf("bucket %s survived the delete", bucket)
+ }
+}
+
+// failGetOn fails reads of key while armed.
+type failGetOn struct {
+ release.Store
+ key string
+ armed atomic.Bool
+}
+
+func (s *failGetOn) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == s.key && s.armed.Load() {
+ return nil, "", errors.New("the bucket could not be read")
+ }
+ return s.Store.Get(ctx, key)
+}
+
+// The delete door's import rule fails closed (preflight M15, M8): a record
+// it cannot read, a finish that fails, and a seed id that cannot name a key
+// each answer main's delete sentence and delete nothing. A finish that
+// fails leaves the record rolled-back for a retry, which completes it.
+func TestImportDeleteFailsClosed(t *testing.T) {
+ f := newImportFixture(t)
+ id := newSeedID(t)
+ f.seedRecord(t, importHost, "", id, seeds.KindImport, seeds.StateUploaded)
+
+ rel := &failGetOn{Store: f.fx.rel, key: provision.RecordKey("bab", fromApp, importHost)}
+ rel.armed.Store(true)
+ f.fx.srv.cfg.Releases = rel
+ if code, out := deleteInstance(t, f, importHost); code != http.StatusBadGateway || str(t, out, "error") != mainDeleteFailed {
+ t.Fatalf("delete with an unreadable record = %d %v", code, out)
+ }
+ f.fx.srv.cfg.Releases = f.fx.rel
+ rec, _ := f.record(t, importHost)
+ requireImportState(t, rec, id, seeds.StateUploaded, "")
+
+ data := f.fx.srv.cfg.SeedData
+ f.fx.srv.cfg.SeedData = nil
+ if code, out := deleteInstance(t, f, importHost); code != http.StatusBadGateway || str(t, out, "error") != mainDeleteFailed {
+ t.Fatalf("delete with a failing finish = %d %v", code, out)
+ }
+ rec, _ = f.record(t, importHost)
+ requireImportState(t, rec, id, seeds.StateRolledBack, "")
+ if rows := importAudit(t, f, "instance-delete"); len(rows) != 0 {
+ t.Fatalf("a failed delete audited: %q", rows)
+ }
+ f.fx.srv.cfg.SeedData = data
+ if code, out := deleteInstance(t, f, importHost); code != http.StatusOK || out["result"] != "deleted" {
+ t.Fatalf("retried delete = %d %v", code, out)
+ }
+ f.requireRecordGone(t, importHost)
+
+ // A seed id that cannot name a key: nothing is written or deleted, and
+ // the shared sentinel key survives.
+ sentinel := seeds.SnapshotKey("bad.shop.example.com", "not-a-seed-id")
+ if _, err := f.data.Put(context.Background(), sentinel, []byte("another record's object"), ""); err != nil {
+ t.Fatal(err)
+ }
+ f.seedRecord(t, "bad.shop.example.com", "", "not-a-seed-id", seeds.KindImport, seeds.StateUploaded)
+ if code, out := deleteInstance(t, f, "bad.shop.example.com"); code != http.StatusBadGateway || str(t, out, "error") != mainDeleteFailed {
+ t.Fatalf("delete of a bad seed id = %d %v", code, out)
+ }
+ if rec, err := f.record(t, "bad.shop.example.com"); err != nil || rec.Seed.State != seeds.StateUploaded {
+ t.Fatalf("record of a bad seed id = %+v, %v; want it untouched", rec, err)
+ }
+ if got := f.rawKey(t, f.data, sentinel); got != "another record's object" {
+ t.Fatalf("sentinel = %q", got)
+ }
+}
+
+// On a claim console the delete of an uncommitted import is refused as
+// every import door is: the finish runs nowhere there, and the claim's
+// ordinary retire would delete the record and leak what the seed made.
+func TestImportDeleteOnAClaimConsole(t *testing.T) {
+ f := newImportFixture(t)
+ id := newSeedID(t)
+ f.seedRecord(t, importHost, "", id, seeds.KindImport, seeds.StateAwaitingUpload)
+ f.fx.srv.cfg.HostClaim = true
+ defer func() { f.fx.srv.cfg.HostClaim = false }()
+ if code, out := deleteInstance(t, f, importHost); code != http.StatusConflict || str(t, out, "error") != seedClaimConsoleRefusal().Msg {
+ t.Fatalf("delete on a claim console = %d %v", code, out)
+ }
+ rec, _ := f.record(t, importHost)
+ requireImportState(t, rec, id, seeds.StateAwaitingUpload, "")
+}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 3fe55d376..e9c51261d 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -1821,6 +1821,17 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusBadRequest, "invalid host")
return
}
+ if answered, finished := s.refuseImportDelete(w, r, acct, app, host); answered {
+ if finished {
+ s.auditActivity(acct, app, principal, Activity{
+ Action: "instance-delete", TargetType: "instance", Target: host, Message: message,
+ Detail: "deleted instance " + host + " (cli)",
+ })
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusOK, map[string]any{"result": "deleted"})
+ }
+ return
+ }
if err := s.retireInstanceRecord(ctx, acct.Sqid, app, host, principal, ""); err != nil {
// An accepted delete is this principal's durable request: audit it
// and announce it before answering 202 with the operation.
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 49cd658a2..8686e5a50 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -694,6 +694,7 @@ func (s *Server) registerCLIRoutes() {
s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/import", svcrole.Allow(svcrole.Instance), s.handleAPIImportUpload)
s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped", svcrole.Allow(svcrole.Instance), s.handleAPIImportStopped)
s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit", svcrole.Allow(svcrole.Instance), s.handleAPIImportCommit)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort", svcrole.Allow(svcrole.Instance), s.handleAPIImportAbort)
s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs", svcrole.Set{}, s.handleAPIRunCreate)
s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}", svcrole.Set{}, s.handleAPIRunGet)
s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/runs/{id}/cancel", svcrole.Set{}, s.handleAPIRunCancel)
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go
index e687adc60..ac91e0547 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -1170,6 +1170,12 @@ var claimFixtures = map[string]claimFixture{
status: http.StatusConflict,
msg: seedClaimConsoleRefusal().Msg,
}},
+ "route POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort": {refused: &claimRefusalFixture{
+ apps: claimRefs("bab/titogo"),
+ do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/old.example.com/import/abort", ""),
+ status: http.StatusConflict,
+ msg: seedClaimConsoleRefusal().Msg,
+ }},
// carlos run waits for the claim's reservation protocol (#250): every
// run route refuses on a host-claim console (claimUnsupported).
"route POST /api/cli/apps/{account}/{app}/instances/{host}/runs": {refused: &claimRefusalFixture{
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index f3ca9f0b3..e69e34cef 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -479,6 +479,7 @@ var exemptFromLegacyPin = map[string]bool{
"PUT /api/cli/apps/{account}/{app}/instances/{host}/import": true,
"POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped": true,
"POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit": true,
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort": true,
}
// TestInstanceRoleMatchesTodaysAppTokenReach is the compatibility pin of spec
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt
index ea195a557..881f2f38c 100644
--- a/internal/console/testdata/claim-routes.txt
+++ b/internal/console/testdata/claim-routes.txt
@@ -171,6 +171,7 @@ route POST /api/cli/apps/{account}/{app}/features
route POST /api/cli/apps/{account}/{app}/instance-token
route POST /api/cli/apps/{account}/{app}/instances
route POST /api/cli/apps/{account}/{app}/instances/{host}/channel
+route POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort
route POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit
route POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped
route POST /api/cli/apps/{account}/{app}/instances/{host}/move