cli: carlos status --layout, and its docs paragraph (plan 4, task 5, step 5)
statusLayoutList fetches /api/cli/status/layout and translates its 404 to the approved message; printStatusLayout renders the approved heading verbatim, sorted by box then host, with a stale box's name suffixed by the approved marker. Wired into RunStatus and statusUsage beside --instances. docs/site/cli.md gains the --layout paragraph and example under "carlos status", after the --instances material, per amendment R22 (no "#### Instance layout" section yet).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4 files changed,
+215
−0
cli.go+18 −0docs/site/cli.md+13 −0status_instances.go+97 −0status_instances_test.go+87 −0
diff --git a/cli.go b/cli.go| index ad82f5462..4c500a4bb 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -3303,6 +3303,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 |
| @@ -3333,6 +3334,13 @@ 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 which layout each instance is on, one row per host, box by |
| + box: flat (shared /data), nested (its own directory), fenced |
| + (mid-move, will not wake), conflict (two paths claim it, |
| + will not wake), foreign (paths the pass will not touch) or |
| + external (another supervisor owns it). Operator only, through |
| + the console, like --instances. |
| ` |
| // RunStatus is the "carlos status" verb: a fixture dry-run and a live read of |
| @@ -3345,6 +3353,7 @@ func RunStatus(args []string) { |
| 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)") |
| acctFilter := fs.String("account", "", "narrow --instances to one account (sqid)") |
| + layoutFlag := fs.Bool("layout", false, "which layout each instance is on, one row per host, box by box (operator only, through the console, like --instances)") |
| consoleFlag := consoleOption(fs, "console to ask (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, statusUsage) |
| fs.Parse(args) |
| @@ -3361,6 +3370,15 @@ func RunStatus(args []string) { |
| } |
| return |
| } |
| + if *layoutFlag { |
| + // Console-backed for the identical reason --instances is: the |
| + // layout report lives in each box's own private snapshot, and the |
| + // console already reads it for its operator page. |
| + if err := statusLayout(os.Stdout, *consoleFlag); err != nil { |
| + log.Fatal(err) |
| + } |
| + 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 cedeb32f0..709a86ad1 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -1519,6 +1519,19 @@ 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` shows which layout each instance is on, box by box. `flat` is the |
| +shared `/data` every instance used to live in. `nested` is the instance's own |
| +directory, where it cannot see its neighbours. `fenced` means a move between |
| +the two is in progress or stopped part way, and `conflict` means two routes |
| +claim the same directory; neither wakes until that is resolved. `foreign` |
| +rows have paths the layout pass does not recognise and leaves alone, and |
| +`external` rows are run by another supervisor. Like `--instances`, it goes |
| +through the console and is operator only. |
| + |
| +```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/status_instances.go b/status_instances.go| index cfacc1511..9e1acb13f 100644 |
| --- a/status_instances.go |
| +++ b/status_instances.go |
| @@ -22,6 +22,7 @@ import ( |
| "io" |
| "net/http" |
| "net/url" |
| + "sort" |
| ) |
| // statusInstanceRow is one awake instance, as the console answers it |
| @@ -133,6 +134,102 @@ func printStatusInstances(w io.Writer, rows []statusInstanceRow, account string) |
| "an instance on another box in a fleet can land under (platform))") |
| } |
| +// statusLayoutRow is one host's layout, as the console answers it |
| +// (internal/console/apistatuslayout.go). Declared here rather than |
| +// imported, the same wire-struct convention statusInstanceRow above |
| +// follows. |
| +type statusLayoutRow struct { |
| + Host string `json:"host"` |
| + Layout string `json:"layout"` |
| + Reason string `json:"reason"` |
| +} |
| + |
| +// statusLayoutBox is one box's layout report. |
| +type statusLayoutBox struct { |
| + Box string `json:"box"` |
| + Mode string `json:"mode"` |
| + Stale bool `json:"stale"` |
| + Rows []statusLayoutRow `json:"rows"` |
| +} |
| + |
| +func (s *apiSession) statusLayoutList(ctx context.Context) ([]statusLayoutBox, 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, and for the same |
| + // reason statusInstanceList's own comment gives: the answer |
| + // names every box's own layout, so the refusal must say |
| + // nothing about whether the caller holds the operator bit. |
| + 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 { |
| + Boxes []statusLayoutBox `json:"boxes"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return nil, fmt.Errorf("status layout: unreadable response: %w", err) |
| + } |
| + return out.Boxes, nil |
| +} |
| + |
| +func statusLayout(w io.Writer, consoleFlag string) error { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + return err |
| + } |
| + if sess == nil { |
| + return 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>") |
| + } |
| + boxes, err := sess.statusLayoutList(context.Background()) |
| + if err != nil { |
| + return fmt.Errorf("status --layout: %w", err) |
| + } |
| + printStatusLayout(w, boxes) |
| + return nil |
| +} |
| + |
| +// printStatusLayout renders the door's answer as one row per host, sorted |
| +// by box then host so two runs diff on the fleet rather than on request |
| +// order — the door itself sorts, but a caller's own render must not depend |
| +// on that staying true. The heading is the approved copy verbatim; rows are |
| +// laid out to match it rather than through a tabwriter, whose dynamic |
| +// column widths would drift the heading away from what was reviewed the |
| +// moment any cell is wider than its header word. |
| +func printStatusLayout(w io.Writer, boxes []statusLayoutBox) { |
| + type line struct{ box, host, layout, reason string } |
| + var lines []line |
| + for _, b := range boxes { |
| + name := b.Box |
| + if b.Stale { |
| + name += " (stale)" |
| + } |
| + for _, r := range b.Rows { |
| + lines = append(lines, line{name, r.Host, r.Layout, r.Reason}) |
| + } |
| + } |
| + if len(lines) == 0 { |
| + mutedf(w, "no boxes have reported a layout yet") |
| + return |
| + } |
| + sort.Slice(lines, func(i, j int) bool { |
| + if lines[i].box != lines[j].box { |
| + return lines[i].box < lines[j].box |
| + } |
| + return lines[i].host < lines[j].host |
| + }) |
| + fmt.Fprintln(w, "BOX HOST LAYOUT REASON") |
| + for _, l := range lines { |
| + fmt.Fprintf(w, "%s %s %s %s\n", l.box, l.host, l.layout, dashIfEmpty(l.reason)) |
| + } |
| +} |
| + |
| // cpuCell renders a fraction of one core. A nil reading prints as a dash: the |
| // unit was sampled but has no CPU delta yet, which is not the same as idle, |
| // and 0.000 is a claim about the process rather than about the sample. |
diff --git a/status_instances_test.go b/status_instances_test.go| index b94695541..f05a220b4 100644 |
| --- a/status_instances_test.go |
| +++ b/status_instances_test.go |
| @@ -2,6 +2,10 @@ package main |
| import ( |
| "bytes" |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| "strings" |
| "testing" |
| ) |
| @@ -41,3 +45,86 @@ func TestPrintStatusInstancesLabelsPlatformAndStatesItsLimits(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// TestPrintStatusLayoutRendersSortedAndMarksStale pins the CLI table for |
| +// `carlos status --layout` (R21): the approved heading, byte for byte; rows |
| +// sorted by box then host regardless of the order the door answered in; and |
| +// a stale box's name suffixed with the approved stale marker. |
| +func TestPrintStatusLayoutRendersSortedAndMarksStale(t *testing.T) { |
| + boxes := []statusLayoutBox{ |
| + {Box: "zeta", Mode: "flat", Stale: false, Rows: []statusLayoutRow{ |
| + {Host: "z.example.com", Layout: "flat"}, |
| + }}, |
| + {Box: "alpha", Mode: "nested", Stale: true, Rows: []statusLayoutRow{ |
| + {Host: "b.example.com", Layout: "foreign", Reason: "db and socket are neither default"}, |
| + {Host: "a.example.com", Layout: "nested"}, |
| + }}, |
| + } |
| + var buf bytes.Buffer |
| + printStatusLayout(&buf, boxes) |
| + out := buf.String() |
| + |
| + if !strings.Contains(out, "BOX HOST LAYOUT REASON") { |
| + t.Fatalf("missing the approved heading:\n%s", out) |
| + } |
| + if !strings.Contains(out, "alpha (stale)") { |
| + t.Errorf("stale box not marked:\n%s", out) |
| + } |
| + if strings.Contains(out, "zeta (stale)") { |
| + t.Errorf("fresh box marked stale:\n%s", out) |
| + } |
| + |
| + // Sorted by box then host: alpha's two rows (host-sorted) come before |
| + // zeta's, regardless of the door's own order. |
| + aIdx := strings.Index(out, "a.example.com") |
| + bIdx := strings.Index(out, "b.example.com") |
| + zIdx := strings.Index(out, "z.example.com") |
| + if aIdx < 0 || bIdx < 0 || zIdx < 0 || !(aIdx < bIdx && bIdx < zIdx) { |
| + t.Fatalf("rows not sorted box-then-host:\n%s", out) |
| + } |
| + if !strings.Contains(out, "db and socket are neither default") { |
| + t.Errorf("missing the foreign row's reason:\n%s", out) |
| + } |
| +} |
| + |
| +// TestStatusLayoutListTranslatesNotFound pins the approved 404 message |
| +// (cli.status.layout.notfound), byte for byte — the same "not found" this |
| +// console gives a non-operator or predates the door with, which is why the |
| +// message covers both readings. |
| +func TestStatusLayoutListTranslatesNotFound(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).statusLayoutList(context.Background()) |
| + if err == nil { |
| + t.Fatal("want an error on 404") |
| + } |
| + want := "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)" |
| + if err.Error() != want { |
| + t.Fatalf("error = %q, want %q", err.Error(), want) |
| + } |
| +} |
| + |
| +// TestStatusLayoutListDecodesBoxes pins the fetch half: the door's |
| +// {"boxes":[...]} shape decodes into statusLayoutBox/statusLayoutRow. |
| +func TestStatusLayoutListDecodesBoxes(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/cli/status/layout" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + fmt.Fprint(w, `{"boxes":[{"box":"ie","mode":"nested","stale":false,"rows":[{"host":"h.example.com","layout":"nested"}]}]}`) |
| + })) |
| + defer ts.Close() |
| + |
| + boxes, err := newTestSession(ts.URL).statusLayoutList(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(boxes) != 1 || boxes[0].Box != "ie" || boxes[0].Mode != "nested" || boxes[0].Stale || |
| + len(boxes[0].Rows) != 1 || boxes[0].Rows[0].Host != "h.example.com" || boxes[0].Rows[0].Layout != "nested" { |
| + t.Fatalf("boxes = %+v", boxes) |
| + } |
| +} |