console: the armed-regions endpoint, flat move views, and the home region's name (#346)
Three pieces, all to titogo's consumer contract (teamtito/titogo#1848):
- GET .../apps/{sqid}/{app}/regions → {"regions":[{code,armed}],"default"}:
every published placement code with the create door's own predicate as
its armed flag (a picker can never offer what create refuses), the home
region under its NAME, sorted, empty when nothing is published.
- CONSOLE_HOME_REGION names the deployment's home for the API only:
records keep region "" (the unconstrained value reconcile scopes by)
and the create door normalizes the name to "" on the way in — the
fleet never sees the code. Moves to the home name are refused plainly
(no staging lane for the home bucket yet).
- The move doors speak the flat view {state,target,updated_at} — never
the record — plus GET .../move answering {"state":"none"} at rest, the
door the tenant's freeze loop polls. MoveState gains updated_at,
stamped by SetRecordMove on every transition.
armedPlacements now carries per-code armed-ness so an EIP-less region
shows as {armed:false} instead of vanishing; refusal messages list armed
codes only.
Closes #346
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEypt7de2X1G84aQ9rNkCf
10 files changed,
+290
−30
cmd/console/main.go+1 −0internal/console/apiinstancemove.go+67 −7internal/console/apiinstancemove_test.go+124 −6internal/console/apiinstances.go+8 −3internal/console/apiregions.go+53 −0internal/console/authcli.go+4 −0internal/console/instancesplacement.go+14 −13internal/console/server.go+9 −1internal/console/svcroutes_test.go+2 −0internal/provision/provision.go+8 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index e3dc2757e..bda5c2259 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -270,6 +270,7 @@ func serve() { |
| // from it. Empty on a CARLOS_DEPLOYMENT_DIR deployment, which |
| // correctly draws no bucket node. |
| Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), |
| + HomeRegion: strings.TrimSpace(os.Getenv("CONSOLE_HOME_REGION")), |
| ReleasePub: pub, |
| Key: key, |
| EdgeIPs: edgeIPs(), |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 69cf3f836..d156cc921 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -74,9 +74,50 @@ func (s *Server) moveGate(w http.ResponseWriter, r *http.Request) (acct *Account |
| return acct, app, host, rec, true |
| } |
| +// moveView is the flat answer every move door speaks — titogo's consumer |
| +// contract (teamtito/titogo#1848, region-doors design): state, target, one |
| +// freshness stamp. "none" is a real state, not an error: a host with no |
| +// move in flight answers 200 {"state":"none"} so a poller needs no 404 |
| +// branch. Target carries the same NAME the caller used — the home code is |
| +// un-normalized on the way out (see homeRegionOut). |
| +type moveView struct { |
| + State string `json:"state"` |
| + Target string `json:"target,omitempty"` |
| + UpdatedAt int64 `json:"updated_at,omitempty"` |
| +} |
| + |
| +func (s *Server) moveViewOf(rec *provision.Record) moveView { |
| + if rec == nil || rec.Move == nil { |
| + return moveView{State: "none"} |
| + } |
| + return moveView{ |
| + State: rec.Move.State, |
| + Target: s.homeRegionOut(rec.Move.To), |
| + UpdatedAt: rec.Move.UpdatedAt.Unix(), |
| + } |
| +} |
| + |
| +// homeRegionIn/Out translate between the home region's NAME (Config's |
| +// CONSOLE_HOME_REGION, what pickers and callers speak — issue #346) and the |
| +// record's "" (the unconstrained value the fleet's reconcile has always |
| +// scoped by). The record NEVER stores the code; the API never shows "". |
| +func (s *Server) homeRegionIn(region string) string { |
| + if s.cfg.HomeRegion != "" && region == s.cfg.HomeRegion { |
| + return "" |
| + } |
| + return region |
| +} |
| + |
| +func (s *Server) homeRegionOut(region string) string { |
| + if region == "" { |
| + return s.cfg.HomeRegion |
| + } |
| + return region |
| +} |
| + |
| // 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. |
| +// flat move view + 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) |
| @@ -92,10 +133,20 @@ func (s *Server) moveTransition(w http.ResponseWriter, r *http.Request, acct *Ac |
| 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}) |
| + writeJSON(w, okStatus, moveView{State: "none"}) |
| return |
| } |
| - writeJSON(w, okStatus, rec) |
| + writeJSON(w, okStatus, s.moveViewOf(rec)) |
| +} |
| + |
| +// handleAPIInstanceMoveStatus answers the poll: the flat view, "none" when |
| +// quiet — the tenant's freeze loop lives on this door. |
| +func (s *Server) handleAPIInstanceMoveStatus(w http.ResponseWriter, r *http.Request) { |
| + _, _, _, rec, ok := s.moveGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, s.moveViewOf(rec)) |
| } |
| func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Request) { |
| @@ -110,11 +161,18 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| jsonError(w, http.StatusBadRequest, "invalid JSON body") |
| return |
| } |
| - to := strings.TrimSpace(req.Region) |
| - if to == "" || !provision.ValidRegion(to) { |
| + to := s.homeRegionIn(strings.TrimSpace(req.Region)) |
| + if strings.TrimSpace(req.Region) == "" || !provision.ValidRegion(to) { |
| jsonError(w, http.StatusBadRequest, "invalid region") |
| return |
| } |
| + // A move to the HOME region ("" after normalization) has no armed-edges |
| + // row to validate against and no staging lane wired for the home's |
| + // bucket yet — refused plainly rather than wedging in preparing. |
| + if to == "" { |
| + jsonError(w, http.StatusBadRequest, "moves to the home region are not supported yet — the home publishes no staging lane") |
| + 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. |
| @@ -125,8 +183,10 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req |
| } |
| if !set[to] { |
| codes := make([]string, 0, len(set)) |
| - for c := range set { |
| - codes = append(codes, c) |
| + for c, armed := range set { |
| + if armed { |
| + codes = append(codes, c) |
| + } |
| } |
| slices.Sort(codes) |
| jsonError(w, http.StatusBadRequest, "region "+to+" is not armed in this deployment — armed: "+strings.Join(codes, ", ")) |
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go| index 1aa6258ee..247322ed1 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -53,17 +53,35 @@ func TestAPIInstanceMoveVerbs(t *testing.T) { |
| 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"}`) |
| + // The poll door answers "none" before any move — 200, never a 404. |
| + code, _, out := rawReq(t, http.MethodGet, moveURL, token, "") |
| + if code != http.StatusOK || !strings.Contains(out, `"state":"none"`) { |
| + t.Fatalf("move status with no move = %d %s, want 200 none", code, out) |
| + } |
| + // Happy path: request → the FLAT view (titogo's consumer contract): |
| + // state, target, updated_at — never the record. |
| + 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 { |
| + var mv struct { |
| + State string `json:"state"` |
| + Target string `json:"target"` |
| + UpdatedAt int64 `json:"updated_at"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &mv); 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) |
| + if mv.State != provision.MovePreparing || mv.Target != "uk" || mv.UpdatedAt == 0 { |
| + t.Fatalf("after request: %+v", mv) |
| + } |
| + // The record itself still carries the nested truth (and region ie). |
| + if code, _, out := rawReq(t, http.MethodGet, u+"/acme.go.tito.io", token, ""); code != http.StatusOK || !strings.Contains(out, `"region":"ie"`) || !strings.Contains(out, provision.MovePreparing) { |
| + t.Fatalf("record after request: %d %s", code, out) |
| + } |
| + // The poll door mirrors it. |
| + if code, _, out := rawReq(t, http.MethodGet, moveURL, token, ""); code != http.StatusOK || !strings.Contains(out, `"target":"uk"`) { |
| + t.Fatalf("move status mid-move = %d %s", code, out) |
| } |
| // Second request while in flight → 409. |
| if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict { |
| @@ -182,3 +200,103 @@ func provisionPooledMoveRefusal(t *testing.T, fx *consoleFixture, token, u strin |
| } |
| return true |
| } |
| + |
| +// The armed-regions endpoint (issue #346): codes + armed flags from the |
| +// edges files (the create door's own predicate), the home region under its |
| +// NAME, and the deployment default — the picker contract. |
| +func TestAPIRegions(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-regions") |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + base := fx.ts.URL + "/api/cli/apps/bab/titogo/regions" |
| + put := func(key, body string) { |
| + t.Helper() |
| + if _, err := fx.rel.Put(context.Background(), key, []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + // No named home, no placement-publishing boxes: empty list, empty |
| + // default — consumers hide their pickers. |
| + code, _, out := rawReq(t, http.MethodGet, base, token, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("regions = %d %s", code, out) |
| + } |
| + var rl struct { |
| + Regions []struct { |
| + Code string `json:"code"` |
| + Armed bool `json:"armed"` |
| + } `json:"regions"` |
| + Default string `json:"default"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &rl); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rl.Regions) != 0 || rl.Default != "" { |
| + t.Fatalf("empty deployment answered %+v", rl) |
| + } |
| + |
| + // A named home + one armed box + one EIP-less placement. |
| + fx.srv.cfg.HomeRegion = "ie" |
| + put("control/edges/uk-box.json", `{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","hosts":{}}`) |
| + put("control/edges/se-box.json", `{"v":1,"box":"se-box","eip":"","placement":"se","hosts":{}}`) |
| + code, _, out = rawReq(t, http.MethodGet, base, token, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("regions = %d %s", code, out) |
| + } |
| + if err := json.Unmarshal([]byte(out), &rl); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := "[{ie true} {se false} {uk true}]" |
| + got := fmt.Sprint(func() (r []struct { |
| + Code string |
| + Armed bool |
| + }) { |
| + for _, x := range rl.Regions { |
| + r = append(r, struct { |
| + Code string |
| + Armed bool |
| + }{x.Code, x.Armed}) |
| + } |
| + return |
| + }()) |
| + if got != want || rl.Default != "ie" { |
| + t.Fatalf("regions = %s default=%q, want %s / ie", got, rl.Default, want) |
| + } |
| +} |
| + |
| +// The home region's NAME normalizes to the record's "" at the create door |
| +// (issue #346): a picker offering "ie" creates exactly what an unconstrained |
| +// create always made, and the fleet's scope rules never see the code. |
| +func TestAPIInstanceCreateNormalizesHomeRegion(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-homenorm") |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + fx.srv.cfg.HomeRegion = "ie" |
| + 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) |
| + } |
| + u := instancesURL(fx, "bab", "titogo") |
| + // "ie" (the home's name, NOT in the armed edges set) is accepted and |
| + // stored as "". |
| + code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"home.go.tito.io","region":"ie"}`) |
| + if code != http.StatusCreated { |
| + t.Fatalf("create region ie (home name) = %d %s, want 201", code, out) |
| + } |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "home.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Region != "" { |
| + t.Fatalf("record region = %q, want the normalized unconstrained value", rec.Region) |
| + } |
| + // A move targeting the home name is refused plainly (no staging lane). |
| + if code, _, out := rawReq(t, http.MethodPost, u+"/home.go.tito.io/move", token, `{"region":"ie"}`); code != http.StatusBadRequest || !strings.Contains(out, "home region") { |
| + t.Fatalf("move to home = %d %s, want the plain refusal", code, out) |
| + } |
| +} |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 954ca0e79..561429364 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -593,7 +593,10 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusBadGateway, "could not check this app's placement") |
| return |
| } |
| - region := strings.TrimSpace(req.Region) |
| + // The home region's NAME normalizes to the record's "" before any |
| + // validation (issue #346): pickers speak the code, records keep the |
| + // unconstrained value reconcile has always scoped by. |
| + region := s.homeRegionIn(strings.TrimSpace(req.Region)) |
| if placed != "" { |
| region = placed |
| } else if !provision.ValidRegion(region) || !instanceRegionAllowed(m.Regions, region) { |
| @@ -615,8 +618,10 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| if placed == "" && region != "" { |
| if set, armed := armedPlacements(ctx, s.cfg.Releases); armed && !set[region] { |
| codes := make([]string, 0, len(set)) |
| - for c := range set { |
| - codes = append(codes, c) |
| + for c, armed := range set { |
| + if armed { |
| + codes = append(codes, c) |
| + } |
| } |
| slices.Sort(codes) |
| jsonError(w, http.StatusBadRequest, |
diff --git a/internal/console/apiregions.go b/internal/console/apiregions.go| new file mode 100644 |
| index 000000000..59365e080 |
| --- /dev/null |
| +++ b/internal/console/apiregions.go |
| @@ -0,0 +1,53 @@ |
| +package console |
| + |
| +import ( |
| + "net/http" |
| + "sort" |
| +) |
| + |
| +// The armed-regions endpoint (issue #346): one authoritative answer to |
| +// "which regions can serve right now", for consumer pickers — titogo's |
| +// signup picker and move doors consume it through a 60s cache and hide |
| +// themselves when it is unreachable, so it is never in a request path. |
| +// |
| +// GET /api/cli/apps/{account}/{app}/regions |
| +// {"regions":[{"code":"ie","armed":true},{"code":"uk","armed":true}], |
| +// "default":"ie"} |
| +// |
| +// Codes are CARLOS placement codes, never AWS region strings. armed is |
| +// EXACTLY the create door's predicate (#329's armedPlacements — a picker |
| +// built on this can never offer what create would refuse): a box publishes |
| +// the code, and has a dial address. The home region appears under its NAME |
| +// (Config.HomeRegion) with armed true — create accepts it by normalizing |
| +// the name to the record's "" — and is also the default. A deployment with |
| +// no named home and no placement-publishing boxes answers empty lists, and |
| +// consumers hide their pickers. |
| +// |
| +// The instance family's gate, deliberately: the answer is deployment |
| +// topology (which regions exist), the same information the create door's |
| +// refusal message already hands the same bearer. |
| +func (s *Server) handleAPIRegions(w http.ResponseWriter, r *http.Request) { |
| + _, _, _, ok := s.instanceGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + type region struct { |
| + Code string `json:"code"` |
| + Armed bool `json:"armed"` |
| + } |
| + set, _ := armedPlacements(r.Context(), s.cfg.Releases) |
| + out := make([]region, 0, len(set)+1) |
| + if s.cfg.HomeRegion != "" { |
| + out = append(out, region{Code: s.cfg.HomeRegion, Armed: true}) |
| + // A box that publishes the home's code explicitly (a future coded |
| + // home) must not appear twice. |
| + delete(set, s.cfg.HomeRegion) |
| + } |
| + for code, armed := range set { |
| + out = append(out, region{Code: code, Armed: armed}) |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code }) |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "regions": out, "default": s.cfg.HomeRegion, |
| + }) |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 4bb4f6bc4..cd8e3f795 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -567,6 +567,9 @@ func (s *Server) registerCLIRoutes() { |
| // door, the ANSWER a credential gets here moves from 401 to 403. |
| s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances-manifest", svcrole.Set{}, s.handleAPIInstanceEnable) |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceList) |
| + // The armed-regions answer for consumer pickers (issue #346) — the |
| + // same gate and role as the list beside it; see handleAPIRegions. |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/regions", svcrole.Allow(svcrole.Instance), s.handleAPIRegions) |
| // Moving a route between channels (channelmove.go). The zero Set, for |
| // the manifest door's reason: this decides what a route SERVES, and a |
| // credential the app itself holds must not be able to walk its own route |
| @@ -588,6 +591,7 @@ func (s *Server) registerCLIRoutes() { |
| // 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("GET /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveStatus) |
| 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) |
diff --git a/internal/console/instancesplacement.go b/internal/console/instancesplacement.go| index 9bebccaa8..26865c727 100644 |
| --- a/internal/console/instancesplacement.go |
| +++ b/internal/console/instancesplacement.go |
| @@ -98,23 +98,24 @@ func readEdgesFiles(ctx context.Context, s release.Store) []edgesFilePlacementVi |
| return out |
| } |
| -// armedPlacements folds the fleet's edges files into the set of placement |
| -// codes an instance may be created into (issue #329): every code published |
| -// by a box that also has a dial address (an EIP — a box nothing can reach |
| -// is not a landing target). armed reports whether ANY file carried a |
| -// placement code at all: false means the fleet predates the field (or the |
| -// read failed) and the gate stays DORMANT — the door then accepts what it |
| -// always accepted, so rolling boxes and console in either order is safe. |
| -func armedPlacements(ctx context.Context, s release.Store) (set map[string]bool, armed bool) { |
| +// armedPlacements folds the fleet's edges files into every published |
| +// placement code and whether it can take an instance today (issue #329): |
| +// armed means some box publishing the code also has a dial address (an |
| +// EIP — a box nothing can reach is not a landing target), so set[code] |
| +// false is a region that EXISTS but cannot land one, which the regions |
| +// endpoint (#346) shows and the create door refuses alike. The second |
| +// return reports whether ANY file carried a placement code at all: false |
| +// means the fleet predates the field (or the read failed) and the create |
| +// gate stays DORMANT — the door then accepts what it always accepted, so |
| +// rolling boxes and console in either order is safe. |
| +func armedPlacements(ctx context.Context, s release.Store) (set map[string]bool, published bool) { |
| set = map[string]bool{} |
| for _, f := range readEdgesFiles(ctx, s) { |
| if f.Placement == "" { |
| continue |
| } |
| - armed = true |
| - if f.EIP != "" { |
| - set[f.Placement] = true |
| - } |
| + published = true |
| + set[f.Placement] = set[f.Placement] || f.EIP != "" |
| } |
| - return set, armed |
| + return set, published |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index 5328ba156..5ec66f115 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -454,7 +454,15 @@ type Config struct { |
| // handler reaching for os.Getenv would hide it from every reader of this |
| // struct. "" is legal — a local CARLOS_DEPLOYMENT_DIR deployment has no |
| // bucket name to draw — and simply draws no bucket node. |
| - Bucket string |
| + Bucket string |
| + // HomeRegion NAMES the deployment's home placement (CONSOLE_HOME_REGION, |
| + // e.g. "ie") for consumer pickers (issue #346). It is a NAME, not a |
| + // scope: records for the home keep region "" (the unconstrained value |
| + // reconcile has always matched) and the create door normalizes this |
| + // code to "" on the way in — so the fleet's boxes never see the code |
| + // and nothing about placement changes when it is set. "" is legal: the |
| + // regions endpoint then reports no home row and an empty default. |
| + HomeRegion string |
| ReleasePub ed25519.PublicKey // pinned channel-pointer verification key |
| // Key signs channel pointers for promote/rollback (CARLOS_RELEASE_KEY). |
| // nil is a legal deployment: the console runs read-only, the promotion |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 59cd0aeaf..f5fac7a98 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -234,6 +234,8 @@ 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}/regions": true, |
| + "GET /api/cli/apps/{account}/{app}/instances/{host}/move": 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, |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 05acf3438..e5ac03829 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -686,6 +686,11 @@ type MoveState struct { |
| State string `json:"state"` |
| RequestedAt time.Time `json:"requested_at"` |
| ReadyAt *time.Time `json:"ready_at,omitempty"` |
| + // UpdatedAt is when the state last changed — stamped by SetRecordMove |
| + // itself on every transition that leaves a move in place, so the |
| + // console's flat move view can carry one freshness stamp without each |
| + // transition re-implementing it. |
| + UpdatedAt time.Time `json:"updated_at"` |
| } |
| // The move states, in order. There is no "done" state: the terminal |
| @@ -732,6 +737,9 @@ func SetRecordMove(ctx context.Context, s release.Store, acct, app, host string, |
| if err := mut(&rec); err != nil { |
| return err |
| } |
| + if rec.Move != nil { |
| + rec.Move.UpdatedAt = time.Now().UTC() |
| + } |
| body, err := json.Marshal(rec) |
| if err != nil { |
| return err |