console: GET /api/cli/status/layout (plan 4, task 5, step 3)
handleAPIStatusLayout mirrors handleAPIStatusInstances' gate exactly (cliUser, IsOperator, bare 404) and reads status.LoadBoxes plus every customer fleet's boxes through the same systemBoxes helper carlos system status already uses, so a fleet box's layout report surfaces here too (finding S14). Registered with an empty role set beside the instances door; svcroutes_test.go's closed-door inventories updated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4 files changed,
+251
−2
internal/console/apistatuslayout.go+89 −0internal/console/apistatuslayout_test.go+151 −0internal/console/authcli.go+4 −0internal/console/svcroutes_test.go+7 −2
diff --git a/internal/console/apistatuslayout.go b/internal/console/apistatuslayout.go| new file mode 100644 |
| index 000000000..06dc27c68 |
| --- /dev/null |
| +++ b/internal/console/apistatuslayout.go |
| @@ -0,0 +1,89 @@ |
| +// `carlos status --layout` (isolated-exec design §8.4, plan 4 task 5): which |
| +// layout each instance is on, box by box — the operator's console-backed |
| +// view of the layout pass's own on-box classification (internal/layout's |
| +// Pass.Classify), rather than a bucket walk with bucket credentials. |
| +// |
| +// GATE: the identical posture handleAPIStatusInstances already has |
| +// (apistatusinstances.go): cliUser, then s.store.IsOperator, then a bare |
| +// byte-identical 404 for anyone else. Registered through cliRoute with an |
| +// EMPTY role set — this is an operator's view across the whole fleet, not a |
| +// workload's, and no service credential of any role reaches it. |
| +package console |
| + |
| +import ( |
| + "log" |
| + "net/http" |
| + "sort" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// apiStatusLayoutRow is one instance-kind route's layout, as the box's own |
| +// pass classified it — see status.RowLayout, which this mirrors on the |
| +// wire. |
| +type apiStatusLayoutRow struct { |
| + Host string `json:"host"` |
| + Layout string `json:"layout"` |
| + Reason string `json:"reason,omitempty"` |
| +} |
| + |
| +// apiStatusLayoutBox is one box's layout report: the mode it last converged |
| +// to, whether its snapshot is stale (status.BoxStale — the same rule every |
| +// other private reader of status/boxes/ uses), and its rows. |
| +type apiStatusLayoutBox struct { |
| + Box string `json:"box"` |
| + Mode string `json:"mode"` |
| + Stale bool `json:"stale"` |
| + Rows []apiStatusLayoutRow `json:"rows"` |
| +} |
| + |
| +func (s *Server) handleAPIStatusLayout(w http.ResponseWriter, r *http.Request) { |
| + addr, ok := s.cliUser(w, r) |
| + if !ok { |
| + return |
| + } |
| + isOp, err := s.store.IsOperator(addr) |
| + if err != nil { |
| + log.Printf("console: api status layout: is-operator: %v", err) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !isOp { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + // systemBoxes is systemapply's own reader (apisystemupdate.go): the |
| + // platform's own status.LoadBoxes plus every customer fleet's boxes |
| + // under fleets/<name>/status/boxes/ — the same union carlos system |
| + // status already answers with, rather than a second, narrower one that |
| + // would miss fleet boxes (finding S14). |
| + boxes, _, err := s.systemBoxes(r.Context(), false) |
| + if err != nil { |
| + log.Printf("console: api status layout: boxes: %v", err) |
| + jsonError(w, http.StatusBadGateway, "could not read the fleet's box snapshots — try again") |
| + return |
| + } |
| + now := time.Now() |
| + out := make([]apiStatusLayoutBox, 0, len(boxes)) |
| + for _, b := range boxes { |
| + if b.Metrics == nil || b.Metrics.Layout == nil { |
| + // A box that predates layout reporting, or has no layout pass |
| + // armed yet (no activator running): nothing to add. |
| + continue |
| + } |
| + rows := make([]apiStatusLayoutRow, 0, len(b.Metrics.Layout.Rows)) |
| + for host, rl := range b.Metrics.Layout.Rows { |
| + rows = append(rows, apiStatusLayoutRow{Host: host, Layout: rl.Layout, Reason: rl.Reason}) |
| + } |
| + sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) |
| + out = append(out, apiStatusLayoutBox{ |
| + Box: b.Label, |
| + Mode: b.Metrics.Layout.Mode, |
| + Stale: status.BoxStale(b, now, status.SnapshotPeriod), |
| + Rows: rows, |
| + }) |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].Box < out[j].Box }) |
| + writeJSON(w, http.StatusOK, map[string]any{"boxes": out}) |
| +} |
diff --git a/internal/console/apistatuslayout_test.go b/internal/console/apistatuslayout_test.go| new file mode 100644 |
| index 000000000..77ec31789 |
| --- /dev/null |
| +++ b/internal/console/apistatuslayout_test.go |
| @@ -0,0 +1,151 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// seedLayoutBox writes a box snapshot carrying a layout report at the given |
| +// store and label — the console-side fixture for a door that reads |
| +// status.LoadBoxes AND the customer-fleet boxes apisystemupdate.go's |
| +// systemBoxes already knows how to find (amendment R15, R21). |
| +func seedLayoutBox(t *testing.T, store release.Store, label string, at time.Time, report *status.LayoutReport) { |
| + t.Helper() |
| + b := status.CollectBox(nil, status.Probes{Release: "v1", Layout: report}, at) |
| + b.Label = label |
| + if err := status.WriteBox(context.Background(), store, b); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// TestAPIStatusLayoutGate: operator only, the identical posture |
| +// handleAPIStatusInstances has (apistatusinstances.go:91-104) — a |
| +// non-operator gets the byte-identical 404 an unknown route gives, so the |
| +// refusal names neither the rows nor whether the caller holds the bit. |
| +func TestAPIStatusLayoutGate(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-apistatuslayout") |
| + ghostStatus, ghostBody := getBearer(t, fx.ts.URL+"/this-api-route-does-not-exist", token) |
| + |
| + if status, _ := getBearer(t, fx.ts.URL+"/api/cli/status/layout", ""); status != http.StatusUnauthorized { |
| + t.Fatalf("unauthenticated = %d; want the CLI door's 401", status) |
| + } |
| + if status, body := getBearer(t, fx.ts.URL+"/api/cli/status/layout", token); status != ghostStatus || string(body) != string(ghostBody) { |
| + t.Fatalf("non-operator = %d %q; want the ghost 404 (%d %q)", status, body, ghostStatus, ghostBody) |
| + } |
| + |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, body := getBearer(t, fx.ts.URL+"/api/cli/status/layout", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("operator = %d %q", status, body) |
| + } |
| + var out struct { |
| + Boxes []apiStatusLayoutBox `json:"boxes"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + t.Fatalf("unreadable: %v: %s", err, body) |
| + } |
| + if out.Boxes == nil { |
| + t.Error("boxes decoded as null; want an empty array") |
| + } |
| +} |
| + |
| +// TestAPIStatusLayoutListsBoxesRowsAndStale pins the door's shape: one |
| +// entry per box that has reported a layout, its rows, and Stale computed |
| +// from status.BoxStale the same way every other private reader gets it — |
| +// plus a box registered to a customer fleet, read from |
| +// fleets/<name>/status/boxes/ the way apisystemupdate.go's systemBoxes |
| +// already does (R15, R21). |
| +func TestAPIStatusLayoutListsBoxesRowsAndStale(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-apistatuslayoutrows") |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now() |
| + |
| + seedLayoutBox(t, fx.rel, "fresh-box", now, &status.LayoutReport{ |
| + Mode: "nested", |
| + Rows: map[string]status.RowLayout{ |
| + "a.example.com": {Layout: "nested"}, |
| + "b.example.com": {Layout: "foreign", Reason: "db and socket are neither default"}, |
| + }, |
| + }) |
| + // Stale: past staleFactor(3)*SnapshotPeriod(5m) = 15 minutes dark. |
| + seedLayoutBox(t, fx.rel, "stale-box", now.Add(-20*time.Minute), &status.LayoutReport{ |
| + Mode: "flat", |
| + Rows: map[string]status.RowLayout{"c.example.com": {Layout: "flat"}}, |
| + }) |
| + |
| + // A customer-fleet box: registered to the registry's fleet table, and |
| + // its own snapshot written under the fleet's prefix — the shape |
| + // systemBoxes reads, distinct from status/boxes/ (finding S14). |
| + reg, err := openRegistry(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.UpsertFleet(registry.Fleet{Name: "pi", Account: fx.sqid, Bucket: "pi-bucket"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg.Close() |
| + fleetStore := release.NewPrefixStore(fx.rel, "fleets/pi/") |
| + seedLayoutBox(t, fleetStore, "fleet-box", now, &status.LayoutReport{ |
| + Mode: "flat", |
| + Rows: map[string]status.RowLayout{"d.example.com": {Layout: "flat"}}, |
| + }) |
| + |
| + status, body := getBearer(t, fx.ts.URL+"/api/cli/status/layout", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET /api/cli/status/layout = %d %q", status, body) |
| + } |
| + var out struct { |
| + Boxes []apiStatusLayoutBox `json:"boxes"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + t.Fatalf("unreadable: %v: %s", err, body) |
| + } |
| + if len(out.Boxes) != 3 { |
| + t.Fatalf("boxes = %+v, want fresh-box, stale-box and fleet-box", out.Boxes) |
| + } |
| + byLabel := map[string]apiStatusLayoutBox{} |
| + for _, b := range out.Boxes { |
| + byLabel[b.Box] = b |
| + } |
| + |
| + fresh := byLabel["fresh-box"] |
| + if fresh.Mode != "nested" || fresh.Stale { |
| + t.Errorf("fresh-box = %+v", fresh) |
| + } |
| + if len(fresh.Rows) != 2 { |
| + t.Fatalf("fresh-box rows = %+v", fresh.Rows) |
| + } |
| + byHost := map[string]apiStatusLayoutRow{} |
| + for _, r := range fresh.Rows { |
| + byHost[r.Host] = r |
| + } |
| + if byHost["a.example.com"].Layout != "nested" || byHost["a.example.com"].Reason != "" { |
| + t.Errorf("a.example.com = %+v", byHost["a.example.com"]) |
| + } |
| + if byHost["b.example.com"].Layout != "foreign" || byHost["b.example.com"].Reason == "" { |
| + t.Errorf("b.example.com = %+v", byHost["b.example.com"]) |
| + } |
| + |
| + stale := byLabel["stale-box"] |
| + if stale.Mode != "flat" || !stale.Stale { |
| + t.Errorf("stale-box = %+v, want Stale true", stale) |
| + } |
| + |
| + fleetBox := byLabel["fleet-box"] |
| + if fleetBox.Mode != "flat" || len(fleetBox.Rows) != 1 || fleetBox.Rows[0].Host != "d.example.com" { |
| + t.Errorf("fleet-box = %+v, want the fleet-prefixed snapshot to surface here", fleetBox) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 2c77fb0c0..0285b486f 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -476,6 +476,10 @@ func (s *Server) registerCLIRoutes() { |
| // handler, since cliRoute's role set can only narrow SERVICE principals |
| // and a person always passes through it. |
| s.cliRoute("GET /api/cli/status/instances", svcrole.Set{}, s.handleAPIStatusInstances) |
| + // carlos status --layout (plan 4, task 5): which layout each instance is |
| + // on, box by box. Same posture as the door above — an empty role set, the |
| + // operator check inside the handler. |
| + s.cliRoute("GET /api/cli/status/layout", svcrole.Set{}, s.handleAPIStatusLayout) |
| // The literal `new` mirrors the browser door (/a/{sqid}/apps/new); no |
| // bare POST .../{app} route exists to collide with, and the mux prefers |
| // the literal if one ever does. |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 44765af64..77f05527d 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -115,6 +115,11 @@ var closedDoors = []string{ |
| // later. The operator check itself is in the handler, because cliRoute's |
| // Set only narrows service principals and a person always passes it. |
| "GET /api/cli/status/instances", |
| + // carlos status --layout (plan 4, task 5): the same fleet-wide, |
| + // subject-shaped cut as the door above — which layout every instance on |
| + // every box is on is an operator's view, not any one app's, so no |
| + // service role is the right holder here either. |
| + "GET /api/cli/status/layout", |
| // Enrolling a BOX into a fleet, and rotating or detaching one, is a |
| // human act with a machine's credentials as its output. Same cut. |
| "POST /api/cli/fleets/{account}/new", |
| @@ -794,8 +799,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 31 { |
| - t.Errorf("walked %d closed doors, want all %d (and 31 today: instance-token, the routes door, instances-manifest, sidecars-manifest, the route-channel move, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, five system doors, two deployment-wide antivirus, and four store-entitlement doors)", |
| + if seen != len(closedDoors) || seen != 32 { |
| + t.Errorf("walked %d closed doors, want all %d (and 32 today: instance-token, the routes door, instances-manifest, sidecars-manifest, the route-channel move, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, five system doors, two deployment-wide antivirus, and four store-entitlement doors)", |
| seen, len(closedDoors)) |
| } |
| } |