console: the residence-move doors — request, cut, abort (#331)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEypt7de2X1G84aQ9rNkCf
4 files changed,
+320
−2
internal/console/apiinstancemove.go+145 −0internal/console/apiinstancemove_test.go+154 −0internal/console/authcli.go+8 −0internal/console/svcroutes_test.go+13 −2
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| new file mode 100644 |
| index 000000000..a3b7ab318 |
| --- /dev/null |
| +++ b/internal/console/apiinstancemove.go |
| @@ -0,0 +1,145 @@ |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "log" |
| + "net/http" |
| + "os" |
| + "slices" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// The residence-move doors (issue #331, instance-move spec): the console |
| +// writes REQUESTS onto the record and bumps the serial; everything that |
| +// touches data happens on the source box's move worker. Three verbs: |
| +// |
| +// POST .../instances/{host}/move {"region":"uk"} → preparing |
| +// POST .../instances/{host}/move/cut → cutting |
| +// DELETE .../instances/{host}/move → aborted |
| +// |
| +// State is read through the ordinary instance GET — the record carries |
| +// `move` verbatim while one is in flight. |
| + |
| +// moveGate is the shared front half: the instance family's gate, the |
| +// byte-identical 404 for a host that names no record, and the class rail — |
| +// only an exec-backed instance route's state is the platform's to move |
| +// (spec §Out of scope), and fleet placements keep their deliberate |
| +// state-does-not-follow semantics. |
| +func (s *Server) moveGate(w http.ResponseWriter, r *http.Request) (acct *Account, app, host string, rec *provision.Record, ok bool) { |
| + _, acct, app, gok := s.instanceGate(w, r) |
| + if !gok { |
| + return nil, "", "", nil, false |
| + } |
| + host = r.PathValue("host") |
| + if !provision.ValidHostShape(host) { |
| + http.NotFound(w, r) |
| + return nil, "", "", nil, false |
| + } |
| + ctx := r.Context() |
| + rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + http.NotFound(w, r) |
| + return nil, "", "", nil, false |
| + } |
| + if err != nil { |
| + log.Printf("console: api instance move %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the instance") |
| + return nil, "", "", nil, false |
| + } |
| + if k := rec.Kind; k != "" && k != "instance" { |
| + jsonError(w, http.StatusBadRequest, "only exec-backed instances move — this route is kind "+k) |
| + return nil, "", "", nil, false |
| + } |
| + if rec.Addr != "" || len(rec.Addrs) > 0 { |
| + jsonError(w, http.StatusBadRequest, "an addr-backed route's process is not platform-managed — nothing to move") |
| + return nil, "", "", nil, false |
| + } |
| + if _, fleet := registry.FleetPlacement(rec.Region); fleet { |
| + jsonError(w, http.StatusBadRequest, "fleet-placed instances keep state-does-not-follow semantics — move is for platform regions") |
| + return nil, "", "", nil, false |
| + } |
| + return acct, app, host, rec, true |
| +} |
| + |
| +// moveTransition applies one guarded transition and maps its outcomes: |
| +// ErrMoveConflict → 409 with the transition's own sentence, success → the |
| +// fresh record + a serial bump so the source box notices in seconds. |
| +func (s *Server) moveTransition(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, mut func(*provision.Record) error, okStatus int) { |
| + ctx := r.Context() |
| + err := provision.SetRecordMove(ctx, s.cfg.Releases, acct.Sqid, app, host, mut) |
| + if errors.Is(err, provision.ErrMoveConflict) { |
| + jsonError(w, http.StatusConflict, err.Error()) |
| + return |
| + } |
| + if err != nil { |
| + log.Printf("console: api instance move %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not write the move — try again") |
| + return |
| + } |
| + s.bumpSerial(ctx) |
| + rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| + if err != nil { |
| + writeJSON(w, okStatus, map[string]any{"ok": true}) |
| + return |
| + } |
| + writeJSON(w, okStatus, rec) |
| +} |
| + |
| +func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Request) { |
| + acct, app, host, rec, ok := s.moveGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + var req struct { |
| + Region string `json:"region"` |
| + } |
| + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| + jsonError(w, http.StatusBadRequest, "invalid JSON body") |
| + return |
| + } |
| + to := strings.TrimSpace(req.Region) |
| + if to == "" || !provision.ValidRegion(to) { |
| + jsonError(w, http.StatusBadRequest, "invalid region") |
| + return |
| + } |
| + // The armed gate is MANDATORY for moves — unlike create there is no |
| + // dormant path, because a move needs a live target box to restore on; |
| + // a deployment with no armed placements cannot be a move target at all. |
| + set, armed := armedPlacements(r.Context(), s.cfg.Releases) |
| + if !armed { |
| + jsonError(w, http.StatusBadRequest, "this deployment publishes no armed regions — nothing to move to") |
| + return |
| + } |
| + if !set[to] { |
| + codes := make([]string, 0, len(set)) |
| + for c := range set { |
| + codes = append(codes, c) |
| + } |
| + slices.Sort(codes) |
| + jsonError(w, http.StatusBadRequest, "region "+to+" is not armed in this deployment — armed: "+strings.Join(codes, ", ")) |
| + return |
| + } |
| + _ = rec |
| + s.moveTransition(w, r, acct, app, host, provision.MoveRequest(to, time.Now()), http.StatusAccepted) |
| +} |
| + |
| +func (s *Server) handleAPIInstanceMoveCut(w http.ResponseWriter, r *http.Request) { |
| + acct, app, host, _, ok := s.moveGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + s.moveTransition(w, r, acct, app, host, provision.MoveMarkCutting(), http.StatusAccepted) |
| +} |
| + |
| +func (s *Server) handleAPIInstanceMoveAbort(w http.ResponseWriter, r *http.Request) { |
| + acct, app, host, _, ok := s.moveGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + s.moveTransition(w, r, acct, app, host, provision.MoveAbort(), http.StatusOK) |
| +} |
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go| new file mode 100644 |
| index 000000000..463f6e4e0 |
| --- /dev/null |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -0,0 +1,154 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// The residence-move doors (issue #331): the console writes guarded |
| +// transitions and serial bumps; the state machine's 400/409 matrix is the |
| +// tenant's whole interface, so every cell is pinned. |
| +func TestAPIInstanceMoveVerbs(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-move") |
| + m := titoManifest() |
| + m.Regions = nil |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + put := func(key, body string) { |
| + t.Helper() |
| + if _, err := fx.rel.Put(context.Background(), key, []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // Create BEFORE arming: once uk-box publishes a placement, the #329 |
| + // gate would refuse region ie (not armed) — the record predates arming, |
| + // exactly like every real record on a fleet that grows regions later. |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { |
| + t.Fatalf("create = %d %s", code, out) |
| + } |
| + put("control/edges/uk-box.json", `{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","data_bucket":"data-uk","data_bucket_region":"eu-west-2","hosts":{}}`) |
| + moveURL := u + "/acme.go.tito.io/move" |
| + |
| + // Unarmed target → 400 naming the set. |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"se"}`); code != http.StatusBadRequest || !strings.Contains(out, "armed: uk") { |
| + t.Fatalf("move to unarmed = %d %s, want 400 naming the set", code, out) |
| + } |
| + // Same region → 409 (the transition's own conflict). |
| + if code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"ie"}`); code != http.StatusBadRequest && code != http.StatusConflict { |
| + t.Fatalf("move to current region = %d %s, want a refusal", code, out) |
| + } else if code == http.StatusBadRequest && !strings.Contains(out, "armed") { |
| + // "ie" is not armed either — refused at the armed gate, also fine. |
| + _ = out |
| + } |
| + // Cut before any move → 409. |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, ""); code != http.StatusConflict { |
| + t.Fatalf("cut with no move = %d %s, want 409", code, out) |
| + } |
| + // Happy path: request → preparing, record carries the move. |
| + code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`) |
| + if code != http.StatusAccepted { |
| + t.Fatalf("move request = %d %s, want 202", code, out) |
| + } |
| + var rec provision.Record |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Move == nil || rec.Move.State != provision.MovePreparing || rec.Move.To != "uk" || rec.Region != "ie" { |
| + t.Fatalf("after request: region=%q move=%+v", rec.Region, rec.Move) |
| + } |
| + // Second request while in flight → 409. |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict { |
| + t.Fatalf("second move = %d %s, want 409", code, out) |
| + } |
| + // Cut from preparing → 409 (only ready cuts). |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, ""); code != http.StatusConflict { |
| + t.Fatalf("cut from preparing = %d %s, want 409", code, out) |
| + } |
| + // Abort from preparing → 200, move cleared; GET shows no move. |
| + if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusOK { |
| + t.Fatalf("abort = %d %s, want 200", code, out) |
| + } |
| + code, _, out = rawReq(t, http.MethodGet, u+"/acme.go.tito.io", token, "") |
| + if code != http.StatusOK || strings.Contains(out, `"move"`) { |
| + t.Fatalf("after abort: %d %s, want no move key", code, out) |
| + } |
| + // Promote to ready out-of-band (the box's act), then cut succeeds and |
| + // abort refuses. |
| + if code, _, _ := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusAccepted { |
| + t.Fatal("re-request") |
| + } |
| + if err := provision.SetRecordMove(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io", provision.MovePromoteReady(time.Now())); err != nil { |
| + t.Fatal(err) |
| + } |
| + if code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, ""); code != http.StatusAccepted { |
| + t.Fatalf("cut from ready = %d %s, want 202", code, out) |
| + } |
| + if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict { |
| + t.Fatalf("abort from cutting = %d %s, want 409 (completes forward)", code, out) |
| + } |
| + // The serial was bumped by the successful writes. |
| + if _, _, err := fx.rel.Get(context.Background(), "control/serial"); err != nil { |
| + t.Fatalf("control/serial not written: %v", err) |
| + } |
| + // 404 discipline: unknown host is the byte-identical not-found. |
| + if code, _, _ := rawReq(t, http.MethodPost, u+"/ghost.go.tito.io/move", token, `{"region":"uk"}`); code != http.StatusNotFound { |
| + t.Fatalf("move on unknown host = %d, want 404", code) |
| + } |
| +} |
| + |
| +// The class rails: only an exec-backed instance route moves. |
| +func TestAPIInstanceMoveClassRails(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-move-rails") |
| + m := titoManifest() |
| + m.Regions = nil |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if _, err := fx.rel.Put(context.Background(), "control/edges/uk-box.json", |
| + []byte(`{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","hosts":{}}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + mk := func(body string) string { |
| + t.Helper() |
| + code, _, out := rawReq(t, http.MethodPost, u, token, body) |
| + if code != http.StatusCreated { |
| + t.Fatalf("create %s = %d %s", body, code, out) |
| + } |
| + var rec provision.Record |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + return rec.Host |
| + } |
| + for _, tc := range []struct { |
| + name, body, wantIn string |
| + }{ |
| + {"static", `{"host":"st.go.tito.io","kind":"static"}`, "kind static"}, |
| + {"addr-backed", `{"host":"ad.go.tito.io","addrs":["10.0.0.1:80"],"kind":"service"}`, "kind service"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + host := mk(tc.body) |
| + code, _, out := rawReq(t, http.MethodPost, u+"/"+host+"/move", token, `{"region":"uk"}`) |
| + if code != http.StatusBadRequest || !strings.Contains(out, tc.wantIn) { |
| + t.Fatalf("move %s = %d %s, want 400 mentioning %q", tc.name, code, out, tc.wantIn) |
| + } |
| + }) |
| + } |
| + // Fleet-placed: region carries fleet/<name>. |
| + host := mk(`{"host":"fl.go.tito.io"}`) |
| + if err := provision.SetRecordRegion(context.Background(), fx.rel, "bab", "titogo", host, "fleet/pi"); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, u+"/"+host+"/move", token, `{"region":"uk"}`) |
| + if code != http.StatusBadRequest || !strings.Contains(out, "fleet") { |
| + t.Fatalf("move fleet-placed = %d %s, want 400 mentioning fleet", code, out) |
| + } |
| + _ = fmt.Sprint() |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 14eab60b2..4bb4f6bc4 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -583,6 +583,14 @@ func (s *Server) registerCLIRoutes() { |
| // keeps serving. Same {host} wildcard, same gate, same byte-identical |
| // 404 as its GET and DELETE siblings. |
| s.cliRoute("PATCH /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceUpdate) |
| + // The residence-move doors (issue #331): requested by the tenant's |
| + // ROUTER with the app-held instance token (titogo's residence design — |
| + // never by an instance about itself... which the role system cannot yet |
| + // express; the Instance role is the same authority the create and |
| + // delete doors already grant this credential). |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveRequest) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveCut) |
| + s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveAbort) |
| s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceDelete) |
| // The zero Set, deliberately: a credential may never mint a credential, |
| // including itself. This is the escalation cut (spec §3). It takes no |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index c7fd77ac8..59cd0aeaf 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -222,10 +222,21 @@ func TestNoRoleReachesAMintFleetOrPolicyDoor(t *testing.T) { |
| // the domains list answer that role has always reached, so this discloses |
| // nothing the list does not. The WRITE beside it is admin-only, and |
| // TestAPIFeaturesRoles pins that an instance credential never reaches it. |
| +// The three move doors (#331) are the fourth, fifth and sixth, together: |
| +// they did not exist before service principals, so listing them in `want` |
| +// would make the legacy map lie. The instance role reaches them |
| +// DELIBERATELY — the tenant's ROUTER drives a move with the app-held |
| +// CARLOS_INSTANCE_TOKEN (the residence design's stated caller), and a |
| +// credential that may already CREATE and DELETE instances (both in `want`) |
| +// is not gaining authority by relocating one. TestAPIInstanceMoveVerbs |
| +// pins what the doors themselves refuse. |
| var exemptFromLegacyPin = map[string]bool{ |
| "GET /api/cli/apps": true, |
| - "GET /api/cli/apps/{account}/{app}/domains/{host}": true, |
| - "GET /api/cli/apps/{account}/{app}/features": true, |
| + "GET /api/cli/apps/{account}/{app}/domains/{host}": true, |
| + "GET /api/cli/apps/{account}/{app}/features": true, |
| + "POST /api/cli/apps/{account}/{app}/instances/{host}/move": true, |
| + "POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut": true, |
| + "DELETE /api/cli/apps/{account}/{app}/instances/{host}/move": true, |
| } |
| // TestInstanceRoleMatchesTodaysAppTokenReach is the compatibility pin of spec |