status: the layout report on Box.Metrics (plan 4, task 5, step 1)
RowLayout and LayoutReport, and Probes/BoxMetrics.Layout to carry them through CollectBox, hostname-keyed like RouteVersions and private for the same reason: BuildFleet never copies Metrics into the public feed. Contract allowlist updated per amendment R15; fixtureProbes exercises both the mode and the rows map so TestFleetSchemaAllowlisted proves no leak (finding S14).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3 files changed,
+117
−2
internal/status/contract_test.go+25 −1internal/status/snapshot.go+42 −1internal/status/snapshot_test.go+50 −0
diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go| index 2aac4256b..a1638b3da 100644 |
| --- a/internal/status/contract_test.go |
| +++ b/internal/status/contract_test.go |
| @@ -72,13 +72,23 @@ var allowedBoxKeys = map[string][]string{ |
| // objects rather than plain strings: "metrics.upstreams.*[]" pins the |
| // per-upstream fields, so a future field on UpstreamState is a deliberate |
| // publication decision too, not a silent ride-along. |
| + // "metrics.layout" (Task 5, isolated-exec plan 4) is the box's layout |
| + // report: which mode it last converged to, and — hostname-keyed, same |
| + // reasoning as metrics.route_versions and metrics.upstreams just above — |
| + // every instance-kind route's word from the layout pass's own Classify. |
| + // Safe on the identical structural ground: BuildFleet's field-by-field |
| + // copy never names "metrics", so no hostname here can reach FleetBox or |
| + // fleet.json regardless of what this map holds. |
| "": {"generated_at", "label", "region", "routes", "load", "capacity", "replication", "adoption_running", "config_path_running", "restart_path_running", "expired_canaries", "metrics"}, |
| "routes": {"static", "resident", "hibernating", "awake", "asleep"}, |
| "replication": {"healthy", "lag_seconds"}, |
| - "metrics": {"mem_total_bytes", "mem_avail_bytes", "load1", "disk_free_bytes", "release", "tunnel", "route_versions", "upstreams"}, |
| + "metrics": {"mem_total_bytes", "mem_avail_bytes", "load1", "disk_free_bytes", "release", "tunnel", "route_versions", "upstreams", "layout"}, |
| "metrics.route_versions": {wildcardAnyKey}, |
| "metrics.upstreams": {wildcardAnyKey}, |
| "metrics.upstreams.*[]": {"addr", "up", "since", "fails", "last_error"}, |
| + "metrics.layout": {"mode", "rows"}, |
| + "metrics.layout.rows": {wildcardAnyKey}, |
| + "metrics.layout.rows.*": {"layout", "reason"}, |
| } |
| // allowedFleetKeys are THE schema for Fleet's JSON output. Same friction as |
| @@ -288,10 +298,24 @@ func fixtureProbes() status.Probes { |
| }, |
| } |
| + // One flat row and one foreign row, so both the bare "layout" field |
| + // (mode) and the wildcarded, host-keyed "rows" map — including a row |
| + // that carries a reason — are actually exercised by the allowlist walk, |
| + // the same reasoning fixtureProbes' own comment gives for upstreams. |
| + layoutReport := &status.LayoutReport{ |
| + Mode: "flat", |
| + Rows: map[string]status.RowLayout{ |
| + "contract-layout.internal": {Layout: "flat"}, |
| + "contract-foreign.internal": {Layout: "foreign", |
| + Reason: `db "x" socket "y" are neither the flat nor the nested defaults`}, |
| + }, |
| + } |
| + |
| return status.Probes{ |
| MachineID: "test-machine-id", |
| Region: "us-west-1", |
| Upstreams: upstreams, |
| + Layout: layoutReport, |
| MemFrac: &memFrac, |
| DiskFrac: &diskFrac, |
| Listening: listening, |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index dd757d7dd..2fd1b06ee 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -130,6 +130,39 @@ type Probes struct { |
| // answer (no file yet, stat failed), means unknown and the host is left |
| // out of Metrics.RouteDBBytes rather than published as 0. |
| DBBytes func(dbPath string) *int64 |
| + |
| + // Layout is the box's layout report (isolated-exec design §8.4): which |
| + // mode the box last converged to and, per instance-kind route, the word |
| + // the layout pass's own Classify gave it. The caller (internal/host's |
| + // statusLoop) builds it from the armed layout.Pass and the routes it |
| + // already listed; nil means no pass is armed on this box, which |
| + // CollectBox carries straight through as "nothing to report" rather |
| + // than guess at a mode. Hostname-keyed, like Aliases and RouteVersions |
| + // above, which is why it lands in Box.Metrics and nowhere else. |
| + Layout *LayoutReport |
| +} |
| + |
| +// RowLayout is one instance-kind route's layout, as the box's layout pass |
| +// classified it (activator.Classify): Layout is one of flat, nested, |
| +// fenced, conflict, foreign or external, and Reason carries the detail for |
| +// anything but flat and nested — a path, or another row's host — which is |
| +// why it is never shown on the console's instance page (see |
| +// instancepage.go's handleAppInstance): a member sees the word, an operator |
| +// reading status/boxes/<label>.json sees the reason too. |
| +type RowLayout struct { |
| + Layout string `json:"layout"` |
| + Reason string `json:"reason,omitempty"` |
| +} |
| + |
| +// LayoutReport is one box's whole layout picture: Mode is the intent the |
| +// box last converged to ("none" before any is recorded, "unreadable" when |
| +// the last read of it failed or named a mode this binary does not know, |
| +// otherwise "flat" or "nested"), and Rows is every instance-kind route on |
| +// the box, keyed by host. It carries real hostnames, so — like RowLayout |
| +// above — it is private: BoxMetrics.Layout, never a field on Box itself. |
| +type LayoutReport struct { |
| + Mode string `json:"mode"` |
| + Rows map[string]RowLayout `json:"rows"` |
| } |
| // UpstreamState is one upstream's health as the edge's prober currently sees |
| @@ -248,6 +281,13 @@ type BoxMetrics struct { |
| // readers. A host whose file could not be sized is absent, never 0; the |
| // whole map is omitted when no route could be sized. |
| RouteDBBytes map[string]int64 `json:"route_db_bytes,omitempty"` |
| + // Layout is Probes.Layout carried straight through — the box's layout |
| + // report, hostname-keyed like RouteVersions and RouteDBBytes above, and |
| + // private for the identical reason: BuildFleet copies Box's fields into |
| + // FleetBox one at a time by name and never names Metrics, so this can |
| + // never reach the public fleet.json. nil (the field absent) means the |
| + // box has no layout pass armed, not "every route reports flat". |
| + Layout *LayoutReport `json:"layout,omitempty"` |
| } |
| // AliasReading is one alias hostname's status as CollectBox saw it this |
| @@ -616,7 +656,7 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| p.DiskFreeBytes != nil || p.Release != "" || p.Tunnel != "" || |
| p.InstanceID != "" || p.Platform != "" || p.SystemChannel != "" || p.System != nil || |
| len(routeVersions) > 0 || len(aliasReadings) > 0 || len(p.Upstreams) > 0 || |
| - len(routeDBBytes) > 0 { |
| + len(routeDBBytes) > 0 || p.Layout != nil { |
| box.Metrics = &BoxMetrics{ |
| MemTotalBytes: p.MemTotalBytes, MemAvailBytes: p.MemAvailBytes, |
| Load1: p.Load1, DiskFreeBytes: p.DiskFreeBytes, |
| @@ -632,6 +672,7 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| // alongside another known field above, and omitempty drops it. |
| Upstreams: p.Upstreams, |
| RouteDBBytes: routeDBBytes, |
| + Layout: p.Layout, |
| } |
| } |
diff --git a/internal/status/snapshot_test.go b/internal/status/snapshot_test.go| index e9518cdeb..9745875d5 100644 |
| --- a/internal/status/snapshot_test.go |
| +++ b/internal/status/snapshot_test.go |
| @@ -48,3 +48,53 @@ func TestBoxMetricsCarryThePlatformSelfUpdateState(t *testing.T) { |
| t.Fatalf("fleet.json leaked the private metrics: %s", fb) |
| } |
| } |
| + |
| +// TestCollectBoxCarriesTheLayoutReport pins Task 5's Probes.Layout: it |
| +// round-trips through CollectBox into Box.Metrics.Layout and onto the wire |
| +// under its json names, and a Probes with none produces no "layout" key at |
| +// all — the same "unknown stays absent, never a guess" rule every other |
| +// Metrics field follows. |
| +func TestCollectBoxCarriesTheLayoutReport(t *testing.T) { |
| + at := time.Date(2026, 9, 25, 9, 0, 0, 0, time.UTC) |
| + report := &LayoutReport{ |
| + Mode: "nested", |
| + Rows: map[string]RowLayout{ |
| + "h.example.com": {Layout: "nested"}, |
| + "f.example.com": {Layout: "foreign", Reason: `db "x" socket "y" are neither the flat nor the nested defaults`}, |
| + }, |
| + } |
| + b := CollectBox(nil, Probes{Layout: report}, at) |
| + if b.Metrics == nil || b.Metrics.Layout == nil { |
| + t.Fatalf("metrics.layout missing: %+v", b.Metrics) |
| + } |
| + if b.Metrics.Layout.Mode != "nested" { |
| + t.Errorf("mode = %q, want nested", b.Metrics.Layout.Mode) |
| + } |
| + if row := b.Metrics.Layout.Rows["h.example.com"]; row.Layout != "nested" || row.Reason != "" { |
| + t.Errorf("h.example.com row = %+v", row) |
| + } |
| + if row := b.Metrics.Layout.Rows["f.example.com"]; row.Layout != "foreign" || row.Reason == "" { |
| + t.Errorf("f.example.com row = %+v", row) |
| + } |
| + out, err := json.Marshal(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, want := range []string{`"layout":{"mode":"nested"`, `"h.example.com":{"layout":"nested"}`, `"f.example.com":{"layout":"foreign","reason":`} { |
| + if !strings.Contains(string(out), want) { |
| + t.Errorf("json lacks %s: %s", want, out) |
| + } |
| + } |
| + |
| + // A Probes with no Layout at all publishes no "layout" key — not the |
| + // field present-and-null, absent entirely, same as every other Metrics |
| + // field with nothing to report. |
| + b2 := CollectBox(nil, Probes{Release: "v1"}, at) |
| + out2, err := json.Marshal(b2) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.Contains(string(out2), `"layout"`) { |
| + t.Fatalf("layout key present with no Layout probe: %s", out2) |
| + } |
| +} |