carlos / platform Public

console: the import's stopped and commit doors (Plan 6 Task 3)

POST .../import/stopped moves uploaded to ready and POST .../import/commit
moves placed to committed, each a seed-guarded record transition after the
import gate, with a serial bump and an audit row. A lost transition answers
SD-N60 through importTransitionFailure. Wires SD-N54, 55, 60, 64, 65, 115.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 425ffed31f707a1cd94fe8ad78a6b1998d57c7e3 parent 947eb9d
7 files changed, +400 −3
  • approved_seeded_copy_test.go +4 −2
  • internal/console/apiinstanceimport.go +87 −0
  • internal/console/apiinstanceimport_test.go +290 −0
  • internal/console/authcli.go +2 −0
  • internal/console/claim_invariant_fixtures_test.go +12 −0
  • internal/console/svcroutes_test.go +3 −1
  • internal/console/testdata/claim-routes.txt +2 −0
diff --git a/approved_seeded_copy_test.go b/approved_seeded_copy_test.go
index 911a8b00f..7232336bf 100644
--- a/approved_seeded_copy_test.go
+++ b/approved_seeded_copy_test.go
@@ -35,12 +35,14 @@ var seededCopyIDs = func() []string {
// Task 3 wires SD-N1, Task 5 SD-N2 to SD-N8 and SD-N113, Task 6 SD-N9 to SD-N13,
// 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 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.
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-N51", "SD-N52", "SD-N53", "SD-N59", "SD-N61", "SD-N63",
+ "SD-N54", "SD-N55", "SD-N60", "SD-N64", "SD-N65", "SD-N115"}
func mustParseSeededRecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/internal/console/apiinstanceimport.go b/internal/console/apiinstanceimport.go
index 9d02793c3..a9fe9fea3 100644
--- a/internal/console/apiinstanceimport.go
+++ b/internal/console/apiinstanceimport.go
@@ -605,3 +605,90 @@ func putSnapshot(ctx context.Context, data release.Store, path, key string) erro
_, err = release.PutObjectFrom(ctx, data, key, f, st.Size(), "")
return err
}
+
+// importTransitionFailure answers a synchronous transition of the import's
+// record that did not land. A guarded transition that finds the import
+// somewhere else (provision.ErrSeedConflict), or a record write that lost
+// its race twice, means another request got there first: SD-N60, and the
+// caller reads the state and decides. A record that is gone is the 404.
+// Anything else is the retry sentence, with the cause logged.
+func (s *Server) importTransitionFailure(w http.ResponseWriter, app, host string, err error) {
+ switch {
+ case errors.Is(err, provision.ErrSeedConflict), errors.Is(err, release.ErrCASConflict):
+ jsonError(w, http.StatusConflict, fmt.Sprintf("%s changed while this request was made. Read its state with carlos instances list and try again.", host))
+ case errors.Is(err, os.ErrNotExist):
+ jsonError(w, http.StatusNotFound, fmt.Sprintf("%s is not an instance of %s", host, app))
+ default:
+ log.Printf("console: import %s: write the record: %v", host, err)
+ jsonError(w, http.StatusBadGateway, importFailed)
+ }
+}
+
+// handleAPIImportStopped is the app saying its own process is down (spec
+// §5.3): uploaded to ready, and a serial bump so the box swaps. The
+// transition is guarded on the seed id and on uploaded, so a stopped that
+// races an abort, a delete or a second stopped changes nothing but its own
+// answer; the check below only picks the sentence.
+func (s *Server) handleAPIImportStopped(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, host, rec, ok := s.importGate(w, r)
+ if !ok {
+ return
+ }
+ if rec.Seed.State != seeds.StateUploaded {
+ jsonError(w, http.StatusConflict, fmt.Sprintf("%s is %s. Mark it stopped once, after its upload.", host, rec.Seed.State))
+ return
+ }
+ checks, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ ref := s.seedRegionReady(checks, rec.Region, true)
+ cancel()
+ if ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
+ // Not the request's cancellation: a client leaving now must not turn a
+ // transition that landed into a missing bump.
+ wctx, wcancel := context.WithTimeout(context.WithoutCancel(r.Context()), instanceStoreTimeout)
+ defer wcancel()
+ if _, err := provision.SetRecordSeed(wctx, s.cfg.Releases, acct.Sqid, app, host, provision.SeedStopped(rec.Seed.ID)); err != nil {
+ s.importTransitionFailure(w, app, host, err)
+ return
+ }
+ s.auditActivity(acct, app, principal, Activity{Action: "import-stopped", TargetType: "instance", Target: host,
+ Detail: fmt.Sprintf("marked the old process of %s stopped", host)})
+ s.bumpSerial(wctx)
+ writeJSON(w, http.StatusOK, map[string]string{"state": seeds.StateReady})
+}
+
+// handleAPIImportCommit is the app's verdict that the instance serves
+// (spec §5.4): placed to committed, then the box clears the gate. A commit
+// is a record transition and nothing else: the verify token is what the
+// edge checks on the app's own requests (spec §5.5), and this door never
+// sees it. Committed already answers 200, so a retried commit is safe.
+func (s *Server) handleAPIImportCommit(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, host, rec, ok := s.importGate(w, r)
+ if !ok {
+ return
+ }
+ switch rec.Seed.State {
+ case seeds.StateCommitted:
+ writeJSON(w, http.StatusOK, map[string]string{"state": seeds.StateCommitted})
+ return
+ case seeds.StatePlaced:
+ case seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateReady, seeds.StateSwapped:
+ jsonError(w, http.StatusConflict, "nothing has been verified yet. Send a request with the verify token first, then commit.")
+ return
+ default:
+ jsonError(w, http.StatusConflict, fmt.Sprintf("%s is %s, so there is nothing to commit.", host, rec.Seed.State))
+ return
+ }
+ wctx, wcancel := context.WithTimeout(context.WithoutCancel(r.Context()), instanceStoreTimeout)
+ defer wcancel()
+ if _, err := provision.SetRecordSeed(wctx, s.cfg.Releases, acct.Sqid, app, host, provision.SeedCommit(rec.Seed.ID)); err != nil {
+ s.importTransitionFailure(w, app, host, err)
+ return
+ }
+ s.auditActivity(acct, app, principal, Activity{Action: "import-committed", TargetType: "instance", Target: host,
+ Detail: fmt.Sprintf("committed the import of %s", host)})
+ s.bumpSerial(wctx)
+ writeJSON(w, http.StatusOK, map[string]string{"state": seeds.StateCommitted})
+}
diff --git a/internal/console/apiinstanceimport_test.go b/internal/console/apiinstanceimport_test.go
index 11189bd8e..83b2aa8cb 100644
--- a/internal/console/apiinstanceimport_test.go
+++ b/internal/console/apiinstanceimport_test.go
@@ -1035,3 +1035,293 @@ func TestImportUploadRefusals(t *testing.T) {
f.requireClean(t)
f.requireNothingUploaded(t)
}
+
+// postImport posts verb ("stopped" or "commit") to host's import door.
+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, "")
+ var out map[string]any
+ _ = json.Unmarshal([]byte(raw), &out)
+ return code, out
+}
+
+// importAudit is the detail of every audit row of action on shop.
+func importAudit(t *testing.T, f fromFixture, action string) []string {
+ t.Helper()
+ events, err := f.fx.st.Events(OpsAccountID, fromApp, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var rows []string
+ for _, e := range events {
+ if e.Action == action {
+ rows = append(rows, e.Detail)
+ }
+ }
+ return rows
+}
+
+// The whole of spec §5.3 and §5.4's commit: stopped before the upload is
+// refused, stopped after it is ready and bumps the serial, commit before
+// placed is refused, and commit after placed lands once and answers 200 when
+// it is retried. The test plays the box for the swap and the place.
+func TestImportStoppedAndCommit(t *testing.T) {
+ f := newImportFixture(t)
+ var logs bytes.Buffer
+ log.SetOutput(&logs)
+ defer log.SetOutput(os.Stderr)
+ body, sha := sqliteBytes(t)
+ code, out := beginImport(t, f, importHost, sha, int64(len(body)))
+ begun, token := f.requireBegun(t, code, out, importHost)
+ id := begun.Seed.ID
+ if code, out := postImport(t, f, importHost, "stopped"); code != http.StatusConflict ||
+ str(t, out, "error") != importHost+" is awaiting-upload. Mark it stopped once, after its upload." {
+ t.Fatalf("stopped before upload = %d %v", code, out)
+ }
+ requireImportState(t, f.uploaded(t, importHost, body), id, seeds.StateUploaded, "")
+
+ before := serial(t, f.fx)
+ if code, out := postImport(t, f, importHost, "stopped"); code != http.StatusOK || len(out) != 1 || out["state"] != "ready" {
+ t.Fatalf("stopped = %d %v", code, out)
+ }
+ if got := serial(t, f.fx); got != before+1 {
+ t.Fatalf("serial after stopped = %d, want %d", got, before+1)
+ }
+ rec, _ := f.record(t, importHost)
+ requireImportState(t, rec, id, seeds.StateReady, "")
+ if rows := importAudit(t, f, "import-stopped"); len(rows) != 1 || rows[0] != "marked the old process of "+importHost+" stopped" {
+ t.Fatalf("import-stopped rows = %q", rows)
+ }
+ // A second stopped is refused: the import is no longer uploaded.
+ if code, out := postImport(t, f, importHost, "stopped"); code != http.StatusConflict ||
+ str(t, out, "error") != importHost+" is ready. Mark it stopped once, after its upload." {
+ t.Fatalf("second stopped = %d %v", code, out)
+ }
+
+ for _, state := range []string{seeds.StateReady, seeds.StateSwapped} {
+ if state == seeds.StateSwapped {
+ f.setSeed(t, provision.SeedSwapped(id))
+ }
+ before = serial(t, f.fx)
+ if code, out := postImport(t, f, importHost, "commit"); code != http.StatusConflict ||
+ str(t, out, "error") != "nothing has been verified yet. Send a request with the verify token first, then commit." {
+ t.Fatalf("commit while %s = %d %v", state, code, out)
+ }
+ if serial(t, f.fx) != before {
+ t.Fatalf("a refused commit while %s bumped the serial", state)
+ }
+ }
+ f.setSeed(t, provision.SeedPlace(id))
+ before = serial(t, f.fx)
+ if code, out := postImport(t, f, importHost, "commit"); code != http.StatusOK || len(out) != 1 || out["state"] != "committed" {
+ t.Fatalf("commit = %d %v", code, out)
+ }
+ if got := serial(t, f.fx); got != before+1 {
+ t.Fatalf("serial after commit = %d, want %d", got, before+1)
+ }
+ rec, _ = f.record(t, importHost)
+ requireImportState(t, rec, id, seeds.StateCommitted, "")
+ // The retried commit changes nothing, audits nothing and bumps nothing.
+ before = serial(t, f.fx)
+ if code, out := postImport(t, f, importHost, "commit"); code != http.StatusOK || out["state"] != "committed" {
+ t.Fatalf("a retried commit = %d %v, want 200", code, out)
+ }
+ if serial(t, f.fx) != before {
+ t.Fatal("a retried commit bumped the serial")
+ }
+ if rows := importAudit(t, f, "import-committed"); len(rows) != 1 || rows[0] != "committed the import of "+importHost {
+ t.Fatalf("import-committed rows = %q", rows)
+ }
+ // Stopped after commit is refused with the committed state.
+ if code, out := postImport(t, f, importHost, "stopped"); code != http.StatusConflict ||
+ str(t, out, "error") != importHost+" is committed. Mark it stopped once, after its upload." {
+ t.Fatalf("stopped after commit = %d %v", code, out)
+ }
+
+ // The token never leaves begin's body: not the log, not an audit row.
+ if strings.Contains(logs.String(), token) {
+ t.Fatal("the token reached the log")
+ }
+ events, _ := f.fx.st.Events(OpsAccountID, fromApp, 100)
+ for _, e := range events {
+ if strings.Contains(e.Detail+e.Message, token) {
+ t.Errorf("the token reached an audit row: %+v", e)
+ }
+ }
+}
+
+// Commit of an import that was refused, aborted or rolled back is the
+// SD-N115 refusal, and writes nothing.
+func TestImportCommitRefusesAnAbandonedImport(t *testing.T) {
+ f := newImportFixture(t)
+ for _, state := range []string{seeds.StateRefused, seeds.StateAborted, seeds.StateRolledBack} {
+ host := state + ".shop.example.com"
+ f.seedRecord(t, host, "", newSeedID(t), seeds.KindImport, state)
+ before := serial(t, f.fx)
+ if code, out := postImport(t, f, host, "commit"); code != http.StatusConflict ||
+ str(t, out, "error") != host+" is "+state+", so there is nothing to commit." {
+ t.Errorf("commit of a %s import = %d %v", state, code, out)
+ }
+ if rec, _ := f.record(t, host); rec.Seed.State != state {
+ t.Errorf("commit moved a %s import to %s", state, rec.Seed.State)
+ }
+ if serial(t, f.fx) != before {
+ t.Errorf("a refused commit of a %s import bumped the serial", state)
+ }
+ }
+ if rows := importAudit(t, f, "import-committed"); len(rows) != 0 {
+ t.Fatalf("import-committed rows = %q", rows)
+ }
+}
+
+// Stopped refuses every state but uploaded, without writing the record.
+func TestImportStoppedRefusesEveryOtherState(t *testing.T) {
+ f := newImportFixture(t)
+ for _, state := range []string{seeds.StateAwaitingUpload, seeds.StateReady, seeds.StateSwapped, seeds.StatePlaced,
+ seeds.StateCommitted, seeds.StateRefused, seeds.StateAborted, seeds.StateRolledBack} {
+ host := state + ".shop.example.com"
+ f.seedRecord(t, host, "", newSeedID(t), seeds.KindImport, state)
+ before := serial(t, f.fx)
+ if code, out := postImport(t, f, host, "stopped"); code != http.StatusConflict ||
+ str(t, out, "error") != host+" is "+state+". Mark it stopped once, after its upload." {
+ t.Errorf("stopped while %s = %d %v", state, code, out)
+ }
+ if rec, _ := f.record(t, host); rec.Seed.State != state {
+ t.Errorf("stopped moved a %s import to %s", state, rec.Seed.State)
+ }
+ if serial(t, f.fx) != before {
+ t.Errorf("a refused stopped while %s bumped the serial", state)
+ }
+ }
+}
+
+// Both doors share the import gate: no record, a malformed host, an
+// ordinary instance and a --from seed are refused as the upload refuses
+// them, and a claim console refuses before it reads the record.
+func TestImportStoppedAndCommitGate(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 _, verb := range []string{"stopped", "commit"} {
+ 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, verb); code != c.code || str(t, out, "error") != c.msg {
+ t.Errorf("%s %s = %d %v, want %d %q", verb, c.name, code, out, c.code, c.msg)
+ }
+ }
+ f.fx.srv.cfg.HostClaim = true
+ if code, out := postImport(t, f, importHost, verb); code != http.StatusConflict ||
+ str(t, out, "error") != "This console runs with claim control on. Seeded instances are not supported here yet." {
+ t.Errorf("%s with claims armed = %d %v", verb, code, out)
+ }
+ f.fx.srv.cfg.HostClaim = false
+ }
+ if rec, _ := f.record(t, importHost); rec.Seed.State != seeds.StatePlaced {
+ t.Fatalf("a refused door moved the import to %s", rec.Seed.State)
+ }
+}
+
+// Stopped asks the region again: a region that can no longer take the
+// import leaves it uploaded.
+func TestImportStoppedAsksTheRegionAgain(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, "")
+ seedReadyBox(t, f.fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "nested", false)
+ before := serial(t, f.fx)
+ if code, out := postImport(t, f, importHost, "stopped"); code != http.StatusConflict ||
+ str(t, out, "error") != "the home region has more than one box. An import needs a region with one box for now." {
+ t.Fatalf("stopped = %d %v", code, out)
+ }
+ rec, _ := f.record(t, importHost)
+ requireImportState(t, rec, begun.Seed.ID, seeds.StateUploaded, "")
+ if serial(t, f.fx) != before {
+ t.Fatal("a refused stopped bumped the serial")
+ }
+}
+
+// movesOnWrite runs hook, once, just before the first CAS write of key: the
+// write that follows loses its CAS, as it would to a concurrent request.
+type movesOnWrite struct {
+ release.Store
+ key string
+ hook func()
+ done atomic.Bool
+}
+
+func (s *movesOnWrite) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if key == s.key && ifMatch != "" && s.done.CompareAndSwap(false, true) {
+ s.hook()
+ }
+ return s.Store.Put(ctx, key, body, ifMatch)
+}
+
+// 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
+// on the seed and on its from-state).
+func TestImportTransitionsLeaveAnImportThatMovedOn(t *testing.T) {
+ for _, c := range []struct{ verb, action, start string }{
+ {"stopped", "import-stopped", seeds.StateUploaded},
+ {"commit", "import-committed", seeds.StatePlaced},
+ } {
+ t.Run(c.verb, func(t *testing.T) {
+ f := newImportFixture(t)
+ id := newSeedID(t)
+ f.seedRecord(t, importHost, "", id, seeds.KindImport, c.start)
+ f.fx.srv.cfg.Releases = &movesOnWrite{Store: f.fx.rel, key: provision.RecordKey("bab", fromApp, importHost), hook: func() {
+ // Another request aborts the import after this one read it.
+ if _, err := provision.SetRecordSeed(context.Background(), f.fx.rel, "bab", fromApp, importHost, provision.SeedAbort(id)); err != nil {
+ t.Error(err)
+ }
+ }}
+ before := serial(t, f.fx)
+ code, out := postImport(t, f, importHost, c.verb)
+ if code != http.StatusConflict || str(t, out, "error") !=
+ importHost+" changed while this request was made. Read its state with carlos instances list and try again." {
+ t.Fatalf("%s = %d %v", c.verb, code, out)
+ }
+ rec, _ := f.record(t, importHost)
+ requireImportState(t, rec, id, seeds.StateAborted, "")
+ if serial(t, f.fx) != before {
+ t.Fatal("a lost transition bumped the serial")
+ }
+ if rows := importAudit(t, f, c.action); len(rows) != 0 {
+ t.Fatalf("%s rows = %q", c.action, rows)
+ }
+ })
+ }
+}
+
+// A record write that fails for any other reason is the retry sentence, and
+// the record is left as it was.
+func TestImportTransitionsWriteFailure(t *testing.T) {
+ f := newImportFixture(t)
+ id := newSeedID(t)
+ f.seedRecord(t, importHost, "", id, seeds.KindImport, seeds.StatePlaced)
+ rel := &failOnceOn{Store: f.fx.rel, key: provision.RecordKey("bab", fromApp, importHost)}
+ f.fx.srv.cfg.Releases = rel
+ rel.armed.Store(true)
+ before := serial(t, f.fx)
+ if code, out := postImport(t, f, importHost, "commit"); code != http.StatusBadGateway || str(t, out, "error") != importFailed {
+ t.Fatalf("commit = %d %v", code, out)
+ }
+ rec, _ := f.record(t, importHost)
+ requireImportState(t, rec, id, seeds.StatePlaced, "")
+ if serial(t, f.fx) != before {
+ t.Fatal("a failed commit bumped the serial")
+ }
+ // The retry lands.
+ if code, out := postImport(t, f, importHost, "commit"); code != http.StatusOK || out["state"] != "committed" {
+ t.Fatalf("retried commit = %d %v", code, out)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 3b9ae9827..49cd658a2 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -692,6 +692,8 @@ func (s *Server) registerCLIRoutes() {
// The import door (seeded-instances spec §5): the instance role, like
// the create door that begins it.
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}/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 e99b02b58..e687adc60 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -1158,6 +1158,18 @@ var claimFixtures = map[string]claimFixture{
status: http.StatusConflict,
msg: seedClaimConsoleRefusal().Msg,
}},
+ "route POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped": {refused: &claimRefusalFixture{
+ apps: claimRefs("bab/titogo"),
+ do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/old.example.com/import/stopped", ""),
+ status: http.StatusConflict,
+ msg: seedClaimConsoleRefusal().Msg,
+ }},
+ "route POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit": {refused: &claimRefusalFixture{
+ apps: claimRefs("bab/titogo"),
+ do: claimRefusedAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/old.example.com/import/commit", ""),
+ 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 8dadb72fe..f3ca9f0b3 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -476,7 +476,9 @@ var exemptFromLegacyPin = map[string]bool{
// create door's footing: every import call takes the instance role, as
// the create that begins the import does. It did not exist before
// service principals.
- "PUT /api/cli/apps/{account}/{app}/instances/{host}/import": true,
+ "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,
}
// TestInstanceRoleMatchesTodaysAppTokenReach is the compatibility pin of spec
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt
index b3bcac8ea..ea195a557 100644
--- a/internal/console/testdata/claim-routes.txt
+++ b/internal/console/testdata/claim-routes.txt
@@ -171,6 +171,8 @@ 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/commit
+route POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped
route POST /api/cli/apps/{account}/{app}/instances/{host}/move
route POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut
route POST /api/cli/apps/{account}/{app}/instances/{host}/runs