carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 80e1c69d8f6c9dfdbb915e76204fcf46b9d96291 parent fddb8f8
4 files changed, +215 −0
  • cli.go +18 −0
  • docs/site/cli.md +13 −0
  • status_instances.go +97 −0
  • status_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)
+ }
+}