carlos status --layout: flat/nested inventory + flat-is-FAIL regression gate
Human
Prompt
Do both: push the build plan to amadan, and start building Increment B (carlos status --layout).
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
Increment B of the layout-migration build plan. Adds an operator-gated, read-only console door GET /api/cli/status/layout that lists every ActivatorManaged route on the box and classifies it via activator.Classify (reused, not reimplemented) into nested/flat/foreign from the route's two path columns; and a carlos status --layout CLI client that renders flat as 'flat FAIL' and exits non-zero if any managed instance is still flat. Touches no spawn path, no running instance, no internal/activator. API-backed per AGENTS.md (works without box access); fenced/conflict are not reported from this registry-only view by design (documented). This is both the migration inventory and the permanent 'flat never again' gate; it lands safely before any instance is moved.
Agent notes
Deliberate deviation from the plan doc's Increment B: served through a dedicated registry-backed console door, NOT internal/status/snapshot.go + agent.go (the --instances path is metrics-backed and only sees AWAKE units, so it can't cover a hibernating managed instance the gate must see). Only flat fails the gate; foreign/fenced render but don't fail. svcroutes_test closed-door count bumped 36->37 for the new door. Reviewer (this session) ran go build ./..., go vet, and the console+root layout/door tests green; full ./... suite left to amadan CI.
8 files changed,
+536
−2
cli.go+29 −0docs/site/cli.md+17 −0internal/console/apistatuslayout.go+107 −0internal/console/apistatuslayout_test.go+145 −0internal/console/authcli.go+5 −0internal/console/svcroutes_test.go+9 −2status_layout.go+130 −0status_layout_test.go+94 −0
CI passed — run details
diff --git a/cli.go b/cli.go| index f96163eed..b443ce516 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -3306,6 +3306,7 @@ func runKeygen(args []string) { |
| const statusUsage = `usage: carlos status --dry-run <dir> |
| carlos status --live |
| carlos status --instances [--account <sqid>] |
| + carlos status --layout |
| --dry-run <dir> build a throwaway fleet stuffed with fixture secrets |
| (hostnames, a machine id, mid-band fractions), run it |
| @@ -3336,6 +3337,15 @@ const statusUsage = `usage: carlos status --dry-run <dir> |
| route registry, so an instance on another box in a fleet |
| can land under (platform). Both limits print with the |
| table. |
| + |
| + --layout the flat-to-nested migration inventory and its regression |
| + gate: one row per activator-managed instance with the |
| + layout it is on — nested (ok), flat (a FAILURE: no |
| + isolation), or foreign. EXITS NON-ZERO when any managed |
| + instance is still flat, so an operator and a CI job both |
| + read flat as the defect it is. Read-only; signed in through |
| + the console (carlos auth login), operator only, same bare |
| + 404 as --instances for anyone else. |
| ` |
| // RunStatus is the "carlos status" verb: a fixture dry-run and a live read of |
| @@ -3347,6 +3357,7 @@ func RunStatus(args []string) { |
| dryRunDir := fs.String("dry-run", "", "build a fixture fleet, publish it into this directory, and verify no leaks") |
| liveFlag := fs.Bool("live", false, "read status/boxes/*.json from the status bucket and print per-host metrics") |
| instancesFlag := fs.Bool("instances", false, "one row per awake instance across the fleet, with the account and app it belongs to (operator only, through the console)") |
| + layoutFlag := fs.Bool("layout", false, "the flat-to-nested migration inventory and gate: one row per managed instance's layout; exits non-zero if any is still flat (operator only, through the console)") |
| acctFilter := fs.String("account", "", "narrow --instances to one account (sqid)") |
| consoleFlag := consoleOption(fs, "console to ask (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, statusUsage) |
| @@ -3364,6 +3375,24 @@ func RunStatus(args []string) { |
| } |
| return |
| } |
| + if *layoutFlag { |
| + // The inventory and the "flat never again" gate (layout-migration build |
| + // plan, Increment B). Console-backed and read-only, like --instances: |
| + // the layout is derived from the registry's path columns, which the |
| + // console can read for a user with no box access. A fetch or auth |
| + // failure is a Fatal (exit 1 via log.Fatal); a successful fetch that |
| + // finds any flat managed instance is the GATE failing, and it exits |
| + // non-zero deliberately rather than erroring — the table has already |
| + // printed and is the answer. |
| + failed, err := statusLayout(os.Stdout, *consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if failed { |
| + os.Exit(1) |
| + } |
| + return |
| + } |
| if *liveFlag { |
| if err := statusLive(os.Stdout); err != nil { |
| log.Fatal(err) |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index c4e6a485b..554318fd3 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -1649,6 +1649,23 @@ fleet-wide while the account each one is attributed to comes from the |
| console's own box's route registry, so on a multi-box deployment an instance |
| running elsewhere can show up under `(platform)` rather than under its tenant. |
| +`--layout` is the flat-to-nested migration's inventory and its permanent |
| +regression gate. It prints one row per activator-managed instance on the |
| +console's box with the layout the instance is on: `nested` (the isolated |
| +target), `flat` (a `FAIL` — a flat instance shares the box's data directory |
| +with every sibling and has no isolation), or `foreign` (paths that are neither |
| +pair, for an operator to look at). It **exits non-zero when any managed |
| +instance is still flat**, so an operator reads "N flat instances" as the defect |
| +it is and a CI job can gate on it — flat can never quietly come back. It is |
| +read-only: it wakes nothing and moves nothing. Like `--instances` it goes |
| +through the console (operator only, same bare 404 for anyone else) and reports |
| +the console's own box's registry, so on a multi-box fleet it is that box's |
| +inventory. |
| + |
| +```sh |
| +carlos status --layout |
| +``` |
| + |
| `--dry-run <dir>` is the self-hoster's leak check. It builds a throwaway fleet |
| stuffed with fixture secrets, runs it through the real publish path into a |
| scratch directory, reads every published object back, and fails if any |
diff --git a/internal/console/apistatuslayout.go b/internal/console/apistatuslayout.go| new file mode 100644 |
| index 000000000..e1342335c |
| --- /dev/null |
| +++ b/internal/console/apistatuslayout.go |
| @@ -0,0 +1,107 @@ |
| +// The layout inventory and its regression gate (`carlos status --layout`, |
| +// layout-migration build plan Increment B): for every ActivatorManaged route |
| +// on this console's box, which layout is the instance on — nested (the |
| +// isolated target), flat (the security defect the whole migration removes), or |
| +// foreign (paths that are neither pair; an operator looks). This is the |
| +// migration's inventory and the permanent "flat never comes back" gate. |
| +// |
| +// Read-only and side-effect free. It reads the registry the economics and |
| +// census passes here already read, and classifies each row from its two path |
| +// columns through activator.Classify — the one classifier the activator itself |
| +// wakes on, reused rather than reimplemented so the gate and the spawn path can |
| +// never disagree about what "flat" is. It touches no instance and spawns |
| +// nothing. |
| +// |
| +// GATE: cliUser + IsOperator, then a byte-identical 404 — the identical posture |
| +// handleAPIStatusInstances uses, and for the same reason: the answer names |
| +// every tenant's host and its layout, which is an operator's view of the whole |
| +// box rather than any one workload's. Registered through cliRoute with an EMPTY |
| +// role set, so no service credential of any role reaches it. |
| +// |
| +// SCOPE, and the one box-fact this door cannot see. Classify decides |
| +// flat/nested/foreign from the route's DBPath and Socket columns alone, which |
| +// the control plane holds on the row. Its other two verdicts — fenced (a |
| +// migration journal exists for the host) and conflict (two hosts share an |
| +// instance identifier) — need box-local facts this API-backed view cannot read |
| +// from the registry, so it passes neither the Journal nor the Peers seam and |
| +// Classify therefore never returns them here. That is the deliberate |
| +// conservative choice the design sanctions for an API-backed view: flat, nested |
| +// and foreign are what the gate needs and are classifiable from the columns; a |
| +// row that is momentarily fenced mid-migration still reads as the flat-or-nested |
| +// shape its columns describe — the shape it is moving to or from, not a wrong |
| +// answer. Like the instances door, the routes come from this console's OWN |
| +// box-local registry, so on a multi-box deployment this is that box's |
| +// inventory; the CLI prints that limit beside the table. |
| +package console |
| + |
| +import ( |
| + "log" |
| + "net/http" |
| + "path/filepath" |
| + "sort" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| +) |
| + |
| +// apiStatusLayout is one ActivatorManaged route's layout. Reason is Classify's |
| +// own explanation for anything but flat and nested (empty for those two). |
| +type apiStatusLayout struct { |
| + Host string `json:"host"` |
| + Layout string `json:"layout"` |
| + Reason string `json:"reason,omitempty"` |
| +} |
| + |
| +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 |
| + } |
| + reg, err := s.openPassRegistry() |
| + if err != nil { |
| + // Not a 404: the caller IS an operator and the route DOES exist, so the |
| + // honest answer is that this console cannot classify without a registry |
| + // to read the path columns from — mirrors the instances door. |
| + log.Printf("console: api status layout: registry: %v", err) |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "this console serves no route registry, so it cannot classify instance layouts") |
| + return |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + log.Printf("console: api status layout: registry list: %v", err) |
| + jsonError(w, http.StatusBadGateway, "could not read the route registry — try again") |
| + return |
| + } |
| + // The data dir is where the flat databases live: the directory holding the |
| + // registry file this box opened (agent.go derives both as |
| + // <dataDir>/registry.db). Classify compares each row's DBPath against the |
| + // flat and nested defaults derived from it; an empty SocketDir takes |
| + // provision.SocketDir, the box default the activator itself uses. |
| + in := activator.LayoutInputs{DataDir: filepath.Dir(s.cfg.Registry)} |
| + rows := []apiStatusLayout{} |
| + for _, rt := range routes { |
| + // A non-ActivatorManaged route (an unflagged socket route, a |
| + // unit-backed or tunnel-served one) is nobody's to migrate or isolate |
| + // and is not the gate's business — not listed, so it can never be |
| + // failed on. |
| + if !activator.ActivatorManaged(rt) { |
| + continue |
| + } |
| + layout, reason := activator.Classify(rt, in) |
| + rows = append(rows, apiStatusLayout{Host: rt.Host, Layout: string(layout), Reason: reason}) |
| + } |
| + // Sorted by host: the registry's order is not something a reader can diff, |
| + // and the CLI prints the rows in the order it receives them. |
| + sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) |
| + writeJSON(w, http.StatusOK, map[string]any{"layout": rows}) |
| +} |
diff --git a/internal/console/apistatuslayout_test.go b/internal/console/apistatuslayout_test.go| new file mode 100644 |
| index 000000000..0fecf247d |
| --- /dev/null |
| +++ b/internal/console/apistatuslayout_test.go |
| @@ -0,0 +1,145 @@ |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// TestAPIStatusLayoutGate: operator only, with the same posture every operator |
| +// door here has — anyone else sees the byte-identical 404 an unknown route |
| +// gets. It matters on this door too: the answer names other people's hosts and |
| +// the layout they run on, so the refusal must give away neither the rows nor |
| +// whether the caller holds the operator bit. It does not hide the PATH — a |
| +// wrong-method request gets the mux's 405 before any of this. |
| +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 { |
| + Layout []apiStatusLayout `json:"layout"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + t.Fatalf("unreadable: %v: %s", err, body) |
| + } |
| + // Never null: a client that decodes null gets a nil slice and prints |
| + // nothing, which reads the same as "no managed instances" for the wrong |
| + // reason. |
| + if out.Layout == nil { |
| + t.Error("layout decoded as null; want an empty array") |
| + } |
| +} |
| + |
| +// statusLayoutRows asks the door and decodes it, failing on any answer that is |
| +// not a 200 carrying an array. |
| +func statusLayoutRows(t *testing.T, fx *consoleFixture, token string) []apiStatusLayout { |
| + t.Helper() |
| + 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 { |
| + Layout []apiStatusLayout `json:"layout"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + t.Fatalf("unreadable: %v: %s", err, body) |
| + } |
| + return out.Layout |
| +} |
| + |
| +// TestAPIStatusLayoutClassifiesFromRouteColumns pins the classification itself: |
| +// the door derives each managed route's layout from its two path columns |
| +// through the real activator.Classify, over this console's own registry. Four |
| +// routes cover the verdicts the gate turns on: |
| +// |
| +// - a FLAT managed instance (the defect) classifies flat; |
| +// - a NESTED one classifies nested (ok); |
| +// - a FOREIGN one (paths that are neither pair) classifies foreign and is |
| +// shown rather than dropped; |
| +// - a non-ActivatorManaged route (here, an instance route with hibernate |
| +// off) is not classified at all and so can never be failed on. |
| +func TestAPIStatusLayoutClassifiesFromRouteColumns(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-apistatuslayout-classify") |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The door reads the data dir as the directory holding the registry file, |
| + // the same derivation the box uses (<dataDir>/registry.db); build the exact |
| + // flat and nested path pairs from it so what is under test is the real |
| + // column comparison, not a string this test agreed with itself about. |
| + dataDir := filepath.Dir(fx.regPath) |
| + |
| + flatHost := "flat.example.com" |
| + fx.addRoute(t, registry.Route{ |
| + Host: flatHost, Kind: "instance", Status: "ready", App: "kass", Hibernate: true, |
| + Socket: provision.DefaultSocket(flatHost), |
| + DBPath: provision.DefaultDBPath(dataDir, flatHost), |
| + }) |
| + |
| + nestedHost := "nested.example.com" |
| + fx.addRoute(t, registry.Route{ |
| + Host: nestedHost, Kind: "instance", Status: "ready", App: "kass", Hibernate: true, |
| + Socket: provision.NestedSocket(nestedHost), |
| + DBPath: provision.NestedDBPath(dataDir, nestedHost), |
| + }) |
| + |
| + foreignHost := "foreign.example.com" |
| + fx.addRoute(t, registry.Route{ |
| + Host: foreignHost, Kind: "instance", Status: "ready", App: "kass", Hibernate: true, |
| + Socket: provision.DefaultSocket(foreignHost), |
| + DBPath: filepath.Join(dataDir, "somewhere-else.db"), |
| + }) |
| + |
| + // Hibernate off => not ActivatorManaged. It must not appear at all, so it |
| + // can never be failed on and never read as flat. |
| + unmanagedHost := "awake.example.com" |
| + fx.addRoute(t, registry.Route{ |
| + Host: unmanagedHost, Kind: "instance", Status: "ready", App: "kass", |
| + Socket: provision.DefaultSocket(unmanagedHost), |
| + DBPath: provision.DefaultDBPath(dataDir, unmanagedHost), |
| + }) |
| + |
| + rows := statusLayoutRows(t, fx, token) |
| + byHost := map[string]apiStatusLayout{} |
| + for _, r := range rows { |
| + byHost[r.Host] = r |
| + } |
| + |
| + if _, ok := byHost[unmanagedHost]; ok { |
| + t.Errorf("a non-ActivatorManaged route appeared in the layout inventory: %+v", byHost[unmanagedHost]) |
| + } |
| + if len(rows) != 3 { |
| + t.Fatalf("got %d managed rows %+v, want exactly the three managed routes", len(rows), rows) |
| + } |
| + |
| + if got := byHost[flatHost].Layout; got != "flat" { |
| + t.Errorf("flat route classified %q, want flat", got) |
| + } |
| + if got := byHost[nestedHost].Layout; got != "nested" { |
| + t.Errorf("nested route classified %q, want nested", got) |
| + } |
| + if got := byHost[foreignHost]; got.Layout != "foreign" || got.Reason == "" { |
| + t.Errorf("foreign route classified %+v, want foreign with a reason", got) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index a74696222..28970f8da 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -481,6 +481,11 @@ 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) |
| + // The flat-to-nested layout inventory and regression gate (layout-migration |
| + // build plan, Increment B). Same EMPTY role set and in-handler operator |
| + // check as the instances door above, for the same reason: it names every |
| + // tenant's host and the layout it runs on, an operator's view of the box. |
| + 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 add8583c4..ef4195529 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -121,6 +121,13 @@ 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", |
| + // The flat-to-nested layout inventory and gate (layout-migration build |
| + // plan, Increment B). Shut to every credential for the instances door's |
| + // reason: it names every tenant's host and the layout it runs on, an |
| + // operator's view of the whole box, and there is no app-scoped reading of |
| + // it. The operator check is in the handler, since cliRoute's Set only |
| + // narrows service principals and a person always passes it. |
| + "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", |
| @@ -814,8 +821,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 36 { |
| - t.Errorf("walked %d closed doors, want all %d (and 36 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, 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 != 37 { |
| + t.Errorf("walked %d closed doors, want all %d (and 37 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout inventory read, five system doors, two deployment-wide antivirus, and four store-entitlement doors)", |
| seen, len(closedDoors)) |
| } |
| } |
diff --git a/status_layout.go b/status_layout.go| new file mode 100644 |
| index 000000000..7860df55a |
| --- /dev/null |
| +++ b/status_layout.go |
| @@ -0,0 +1,130 @@ |
| +package main |
| + |
| +// `carlos status --layout` — the flat-to-nested migration inventory and its |
| +// permanent regression gate (layout-migration build plan, Increment B). |
| +// |
| +// It asks the console, for every ActivatorManaged instance on its box, which |
| +// layout the instance is on: nested (the isolated target — ok), flat (the |
| +// security defect the whole migration removes — a FAILURE), or foreign (paths |
| +// that are neither pair; an operator looks). A flat managed instance makes the |
| +// command exit non-zero, so an operator reads "N flat instances" as the defect |
| +// it is, and a CI job can gate on it so flat can never come back silently. |
| +// |
| +// Console-backed, like `--instances` and for the same reason (AGENTS.md |
| +// "operate via API and CLI"): the classification is a read of the registry's |
| +// path columns, which the console can do for a user who has no box access at |
| +// all. It is strictly read-only — it wakes nothing, moves nothing, and spawns |
| +// nothing. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net/http" |
| +) |
| + |
| +// statusLayoutRow is one managed instance's layout, as the console answers it |
| +// (internal/console/apistatuslayout.go). Declared here rather than imported, |
| +// the convention every other CLI-side wire struct follows — and the CLI never |
| +// links the activator, so it speaks the layout as the string the wire carries. |
| +type statusLayoutRow struct { |
| + Host string `json:"host"` |
| + Layout string `json:"layout"` |
| + Reason string `json:"reason"` |
| +} |
| + |
| +func (s *apiSession) statusLayoutList(ctx context.Context) ([]statusLayoutRow, error) { |
| + status, body, err := s.getJSON(ctx, "/api/cli/status/layout") |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + if status == http.StatusNotFound { |
| + // The same bare 404 an unknown route gives: the answer names other |
| + // people's hosts, so the refusal says nothing about them and |
| + // nothing about who holds the operator bit. This message therefore |
| + // covers both readings, because the caller cannot tell them apart. |
| + return nil, errors.New("not found — this needs the fleet-operator bit on your account, " + |
| + "or this console predates `carlos status --layout` (deploy a console built from current main)") |
| + } |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var out struct { |
| + Layout []statusLayoutRow `json:"layout"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return nil, fmt.Errorf("status layout: unreadable response: %w", err) |
| + } |
| + return out.Layout, nil |
| +} |
| + |
| +// statusLayout fetches and prints the inventory and reports whether the gate |
| +// failed (any managed instance still flat). The caller turns a true into a |
| +// non-zero process exit; it is returned rather than taken here so the one place |
| +// that owns the process's exit code (RunStatus) stays the one that sets it. |
| +func statusLayout(w io.Writer, consoleFlag string) (failed bool, err error) { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + return false, err |
| + } |
| + if sess == nil { |
| + return false, errors.New("carlos status --layout works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Unset the store env and run:\n" + |
| + "carlos auth login --console <url>") |
| + } |
| + rows, err := sess.statusLayoutList(context.Background()) |
| + if err != nil { |
| + return false, fmt.Errorf("status --layout: %w", err) |
| + } |
| + return printStatusLayout(w, rows), nil |
| +} |
| + |
| +// layoutFlat is the one layout the gate fails on. A plain string rather than |
| +// an import of activator.LayoutFlat because the CLI speaks to the console only |
| +// over the wire — it never links the activator — and the wire value is the |
| +// string "flat" (activator.Layout's underlying type). |
| +const layoutFlat = "flat" |
| + |
| +// printStatusLayout renders the inventory and returns whether any managed |
| +// instance is flat. |
| +// |
| +// Flat is rendered loud — the LAYOUT cell reads "flat FAIL" — and is the only |
| +// verdict that fails the gate. Nested is ok. Anything else (foreign today; |
| +// fenced or conflict if a future box-backed view ever reports them) is shown |
| +// as itself and does NOT fail the gate: flat is the security regression this |
| +// gate exists for, a foreign row is an operator-look item rather than the flat |
| +// defect, and a fenced row is a transient mid-migration state that settles. |
| +// Only flat is "never again". The FAIL marker is content, not colour, so a |
| +// pipe or a CI run sees it byte-identically. |
| +func printStatusLayout(w io.Writer, rows []statusLayoutRow) (failed bool) { |
| + if len(rows) == 0 { |
| + mutedf(w, "no activator-managed instances on this console's box") |
| + return false |
| + } |
| + t := newTable(w) |
| + fmt.Fprintln(t, "HOST\tLAYOUT\tREASON") |
| + flats := 0 |
| + for _, r := range rows { |
| + verdict := r.Layout |
| + if r.Layout == layoutFlat { |
| + verdict = "flat FAIL" |
| + flats++ |
| + } |
| + fmt.Fprintf(t, "%s\t%s\t%s\n", dashIfEmpty(r.Host), verdict, dashIfEmpty(r.Reason)) |
| + } |
| + t.Flush() |
| + if flats > 0 { |
| + warnf(w, "FAIL: %d of %d managed instance(s) still on the flat layout — no isolation; carlos status --layout exits non-zero", |
| + flats, len(rows)) |
| + } else { |
| + mutedf(w, "ok: no managed instance is on the flat layout (%d classified)", len(rows)) |
| + } |
| + // The scope limit, printed every time for the same reason the instances |
| + // door prints its own: the classification is this console's own box-local |
| + // registry, so on a multi-box fleet this is that box's inventory, not the |
| + // whole fleet's — and a short list must not read as a complete one. |
| + mutedf(w, "(classified from the console's own box's route registry; on a multi-box fleet this is that box's inventory)") |
| + return flats > 0 |
| +} |
diff --git a/status_layout_test.go b/status_layout_test.go| new file mode 100644 |
| index 000000000..e298e9d29 |
| --- /dev/null |
| +++ b/status_layout_test.go |
| @@ -0,0 +1,94 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestPrintStatusLayoutFailsOnFlat is the gate itself at the presentation |
| +// seam: a single flat managed instance has to render as a loud FAIL and make |
| +// the function report the gate failed, which is what the CLI turns into a |
| +// non-zero process exit. Flat is the security defect the whole migration |
| +// removes, so a run that contains one must not read as ok. |
| +func TestPrintStatusLayoutFailsOnFlat(t *testing.T) { |
| + rows := []statusLayoutRow{ |
| + {Host: "nested.example.com", Layout: "nested"}, |
| + {Host: "flat.example.com", Layout: "flat"}, |
| + } |
| + var buf bytes.Buffer |
| + failed := printStatusLayout(&buf, rows) |
| + if !failed { |
| + t.Fatal("printStatusLayout reported ok with a flat managed instance present") |
| + } |
| + out := buf.String() |
| + for _, want := range []string{ |
| + "flat.example.com", "flat FAIL", "nested.example.com", |
| + "FAIL:", "still on the flat layout", |
| + "console's own box's route registry", |
| + } { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("missing %q in:\n%s", want, out) |
| + } |
| + } |
| +} |
| + |
| +// TestPrintStatusLayoutAllNestedIsOK: a fleet that is entirely nested is the |
| +// end state, and it must read as ok with a zero exit — the gate is only tripped |
| +// by flat, never by a clean inventory. |
| +func TestPrintStatusLayoutAllNestedIsOK(t *testing.T) { |
| + rows := []statusLayoutRow{ |
| + {Host: "a.example.com", Layout: "nested"}, |
| + {Host: "b.example.com", Layout: "nested"}, |
| + } |
| + var buf bytes.Buffer |
| + if printStatusLayout(&buf, rows) { |
| + t.Fatal("printStatusLayout reported failed for an all-nested fleet") |
| + } |
| + out := buf.String() |
| + if !strings.Contains(out, "ok: no managed instance is on the flat layout") { |
| + t.Errorf("missing the ok verdict in:\n%s", out) |
| + } |
| + if strings.Contains(out, "FAIL") { |
| + t.Errorf("an all-nested fleet rendered a FAIL in:\n%s", out) |
| + } |
| +} |
| + |
| +// TestPrintStatusLayoutForeignAndFencedRenderButDoNotFail: foreign (and a |
| +// fenced row, should a future box-backed view ever report one) are shown as |
| +// themselves with their reason, but neither trips the gate. Only flat is the |
| +// hard failure; a foreign row is an operator-look item and a fenced row is a |
| +// transient mid-migration state. |
| +func TestPrintStatusLayoutForeignAndFencedRenderButDoNotFail(t *testing.T) { |
| + rows := []statusLayoutRow{ |
| + {Host: "weird.example.com", Layout: "foreign", Reason: `db "/data/odd.db" socket "/x.sock" are neither the flat nor the nested defaults`}, |
| + {Host: "moving.example.com", Layout: "fenced", Reason: "layout migration in flight"}, |
| + {Host: "good.example.com", Layout: "nested"}, |
| + } |
| + var buf bytes.Buffer |
| + if printStatusLayout(&buf, rows) { |
| + t.Fatal("foreign/fenced rows tripped the gate; only flat should") |
| + } |
| + out := buf.String() |
| + for _, want := range []string{ |
| + "weird.example.com", "foreign", "neither the flat nor the nested", |
| + "moving.example.com", "fenced", "migration in flight", |
| + } { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("missing %q in:\n%s", want, out) |
| + } |
| + } |
| +} |
| + |
| +// TestPrintStatusLayoutEmpty: no managed instances is not a failure. A box with |
| +// nothing to classify says so and exits zero rather than reading as a clean |
| +// pass that happened to classify something. |
| +func TestPrintStatusLayoutEmpty(t *testing.T) { |
| + var buf bytes.Buffer |
| + if printStatusLayout(&buf, nil) { |
| + t.Fatal("an empty inventory tripped the gate") |
| + } |
| + if !strings.Contains(buf.String(), "no activator-managed instances") { |
| + t.Errorf("missing the empty-inventory line in:\n%s", buf.String()) |
| + } |
| +} |