carlos / platform Public

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
Paul Campbell pushed by paul@keymail.dev 0011557f20cad3059699fcfe5d18339e1faee27c parent 47ce665
4 files changed, +320 −2
  • internal/console/apiinstancemove.go +145 −0
  • internal/console/apiinstancemove_test.go +154 −0
  • internal/console/authcli.go +8 −0
  • internal/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