status/console/cli: render raw host metrics privately, stale hosts as unreachable
Task 5 gave Box a raw Metrics block and Task 6 filled it; nothing read it back. This surfaces it on the two surfaces allowed to see raw numbers — the session-gated operator fleet page and an operator's own shell — and nowhere else: fleet.json's shape and the public status page are untouched. status gains LoadBoxes (the exported loadBoxes: one parser of the status/boxes/ shape, one skip-corrupt rule) and BoxStale, which BuildFleet's own per-box staleness now calls, so the published boxes table and every private reader can never drift into disagreeing about which host is dark. SnapshotPeriod moves the 5-minute tick into the status package, since the console and the CLI have no agent-local const to borrow. The rule both renderings enforce: an unreachable host shows how long it has been dark and NOT ONE of its last readings. On the console that suppression happens in the view model (HostRow.Metrics is nil'd for a stale box), not in the template, so a dead box's numbers are never handed to the page at all. The host detail is an extra read on a page that already had its answer, so a boxes read that fails costs the section and a log line, never the page. carlos status grows -live, reading the same bucket the agent's status loop writes (host.ResolveStatusBucket, exported over the existing helper). -dry-run is byte-identical; the usage text's "no live mode" paragraph is now false and gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+655
−17
cli.go+119 −11internal/console/server.go+28 −1internal/console/server_test.go+127 −0internal/console/templates/fleet.html+36 −0internal/console/view.go+84 −0internal/host/agent.go+14 −2internal/status/fleet.go+46 −3internal/status/status_test.go+74 −0statuslive_test.go+127 −0
diff --git a/cli.go b/cli.go| index a3f268290..b4a56b284 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -19,12 +19,14 @@ import ( |
| "os" |
| "path/filepath" |
| "regexp" |
| + "sort" |
| "strings" |
| "text/tabwriter" |
| "time" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/host" |
| "github.com/carlosframework/platform/internal/ledger" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -1643,12 +1645,13 @@ func runKeygen(args []string) { |
| } |
| // statusUsage is shared between "no flag given" and "-h/--help" so an |
| -// operator gets the same explanation either way: this verb only has a |
| -// dry-run mode. The fleet status page's LIVE publish loop (CollectBox -> |
| -// WriteBox on every box, BuildFleet -> PublishFleet on the lease holder) |
| -// runs inside the box agent's own 5-minute tick, not from a one-shot CLI |
| -// invocation — there is deliberately no "carlos status publish" here. |
| +// operator gets the same explanation either way: this verb reads and checks, |
| +// it never publishes. The fleet status page's PUBLISH loop (CollectBox -> |
| +// WriteBox on every box, BuildFleet -> PublishFleet on the lease holder) runs |
| +// inside the box agent's own 5-minute tick — there is deliberately no |
| +// "carlos status publish" here; -live only reads what those ticks wrote. |
| const statusUsage = `usage: carlos status -dry-run <dir> |
| + carlos status -live |
| -dry-run <dir> build a throwaway fleet stuffed with fixture secrets |
| (hostnames, a machine id, mid-band fractions), run it |
| @@ -1659,19 +1662,32 @@ const statusUsage = `usage: carlos status -dry-run <dir> |
| scratch directory before ever pointing this binary at a |
| real bucket. |
| -carlos status has no live mode: the fleet status page is published every 5 |
| -minutes by the box agent's own status lease holder (see "carlos agent"), |
| -never by a one-shot CLI invocation. |
| + -live read status/boxes/*.json out of the status bucket and print |
| + one line per host: release, tunnel state, memory, load and |
| + free disk — raw figures, never published on the public |
| + status page. A host that has stopped reporting prints as |
| + UNREACHABLE with how long it has been dark and none of its |
| + last readings. Needs bucket credentials and |
| + CARLOS_STATUS_BUCKET (or CARLOS_DEPLOYMENT_BUCKET, the same |
| + defaulting the agent's own status loop applies). |
| ` |
| -// RunStatus is the "carlos status" verb: dry-run only. Modeled on ops.Run's |
| -// FlagSet shape (main.go's "ops" case), but flatter — status has exactly one |
| -// mode today, so there is no sub-verb dispatch to build out yet. |
| +// RunStatus is the "carlos status" verb: a fixture dry-run and a live read of |
| +// what the fleet's boxes last reported. Modeled on ops.Run's FlagSet shape |
| +// (main.go's "ops" case), but flatter — two independent modes, no sub-verb |
| +// dispatch. |
| func RunStatus(args []string) { |
| fs := flag.NewFlagSet("status", flag.ExitOnError) |
| 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") |
| fs.Parse(args) |
| + if *liveFlag { |
| + if err := statusLive(os.Stdout); err != nil { |
| + log.Fatal(err) |
| + } |
| + return |
| + } |
| if *dryRunDir == "" { |
| fmt.Fprint(os.Stderr, statusUsage) |
| os.Exit(1) |
| @@ -1680,3 +1696,95 @@ func RunStatus(args []string) { |
| log.Fatal(err) |
| } |
| } |
| + |
| +// statusLive prints what every box in the fleet last reported about itself, |
| +// read straight from status/boxes/ — the private snapshots, raw numbers and |
| +// all, which is why this needs the operator's own bucket credentials and why |
| +// nothing here is ever served publicly (see internal/status's package |
| +// comment). Bucket resolution is host.ResolveStatusBucket, the identical |
| +// CARLOS_STATUS_BUCKET-then-CARLOS_DEPLOYMENT_BUCKET rule the agent's status |
| +// loop uses, so this reads exactly the bucket the agents write. |
| +func statusLive(w io.Writer) error { |
| + ctx := context.Background() |
| + bucket := host.ResolveStatusBucket(os.Getenv) |
| + if bucket == "" { |
| + return fmt.Errorf("set CARLOS_STATUS_BUCKET or CARLOS_DEPLOYMENT_BUCKET") |
| + } |
| + s, err := release.NewS3Store(ctx, bucket) |
| + if err != nil { |
| + return err |
| + } |
| + boxes, err := status.LoadBoxes(ctx, s) |
| + if err != nil { |
| + return err |
| + } |
| + printLiveBoxes(w, boxes, time.Now()) |
| + return nil |
| +} |
| + |
| +// printLiveBoxes is statusLive's rendering half, split out so it can be |
| +// asserted on without a bucket. One line per box, label-sorted so two runs |
| +// diff on the fleet rather than on list order. |
| +// |
| +// A stale box (status.BoxStale — the same rule the console and the published |
| +// boxes table use) prints UNREACHABLE and how long it has been dark, and |
| +// deliberately NOT its last readings: those numbers described a machine that |
| +// has since stopped answering, and printing them in the same columns as a |
| +// live host's is how an outage gets read as fine. A fresh box that carries no |
| +// metrics block at all is a host running code from before the block existed — |
| +// which is a fact worth stating, not a row of zeros. |
| +func printLiveBoxes(w io.Writer, boxes []status.Box, now time.Time) { |
| + sorted := append([]status.Box(nil), boxes...) |
| + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Label < sorted[j].Label }) |
| + for _, b := range sorted { |
| + if status.BoxStale(b, now, status.SnapshotPeriod) { |
| + fmt.Fprintf(w, "%-24s %-12s UNREACHABLE (last heartbeat %s ago)\n", |
| + b.Label, orDash(b.Region), now.Sub(b.GeneratedAt).Round(time.Minute)) |
| + continue |
| + } |
| + m := b.Metrics |
| + if m == nil { |
| + fmt.Fprintf(w, "%-24s %-12s ok (no metrics block — pre-upgrade host)\n", b.Label, orDash(b.Region)) |
| + continue |
| + } |
| + fmt.Fprintf(w, "%-24s %-12s release=%s tunnel=%s mem=%s/%s load=%s disk-free=%s\n", |
| + b.Label, orDash(b.Region), orDash(m.Release), orDash(m.Tunnel), |
| + humanBytes(m.MemAvailBytes), humanBytes(m.MemTotalBytes), |
| + humanFloat(m.Load1), humanBytes(m.DiskFreeBytes)) |
| + } |
| +} |
| + |
| +// orDash, humanBytes and humanFloat render one field of a metrics block. |
| +// Unknown is a dash in all three — never a zero, never a blank column: the |
| +// status package carries "I couldn't measure this" as a nil pointer all the |
| +// way to here precisely so the reader can tell it apart from a measured zero. |
| +func orDash(s string) string { |
| + if s == "" { |
| + return "-" |
| + } |
| + return s |
| +} |
| + |
| +func humanBytes(v *int64) string { |
| + if v == nil { |
| + return "-" |
| + } |
| + b := float64(*v) |
| + switch { |
| + case b >= 1<<30: |
| + return fmt.Sprintf("%.1fGB", b/(1<<30)) |
| + case b >= 1<<20: |
| + return fmt.Sprintf("%.1fMB", b/(1<<20)) |
| + case b >= 1<<10: |
| + return fmt.Sprintf("%.1fKB", b/(1<<10)) |
| + default: |
| + return fmt.Sprintf("%dB", *v) |
| + } |
| +} |
| + |
| +func humanFloat(v *float64) string { |
| + if v == nil { |
| + return "-" |
| + } |
| + return fmt.Sprintf("%.2f", *v) |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index e0031150a..e9bacda9b 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -97,6 +97,16 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{ |
| return fmt.Sprintf("%d B", *v) |
| } |
| }, |
| + // loadOrDash renders a 1-minute load average: an absolute number of |
| + // runnable tasks, not a fraction, so pct (which multiplies by 100 and |
| + // stamps a %) would be a lie about the same *float64. nil is the same |
| + // em-dash every unknown on this console gets. |
| + "loadOrDash": func(v *float64) string { |
| + if v == nil { |
| + return "—" |
| + } |
| + return fmt.Sprintf("%.2f", *v) |
| + }, |
| // mapGet reads one named field out of Fleet.Instances — a map so a |
| // template range would iterate it key-sorted, alphabetically, which |
| // scrambles the static/resident/hibernating/awake/asleep order the |
| @@ -318,6 +328,14 @@ type page struct { |
| Fleet *status.Fleet // /fleet: nil means no snapshot published yet |
| + // /fleet, below the published boxes table: the per-host PRIVATE detail |
| + // read straight out of status/boxes/ (HostRows, view.go). Raw numbers |
| + // render here and nowhere public — this page is session-gated to |
| + // operators, fleet.json is not. Nil renders no section at all: no boxes |
| + // yet, no bucket handle, or a boxes read that failed are all "no detail |
| + // to show", never a reason to fail the page. |
| + Hosts []HostRow |
| + |
| // /fleet/metrics: the operator dashboard (Task 11), both from LoadMetrics |
| // (metricsview.go) and both nil-safe to render — no boxes and no firing |
| // alerts are the normal, healthy, empty state, not an error. |
| @@ -1030,7 +1048,16 @@ func (s *Server) handleFleet(w http.ResponseWriter, r *http.Request) { |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read fleet status. Please try again.") |
| return |
| } |
| - p := page{Title: "Fleet status", Address: addr, CSRF: raw, Fleet: f} |
| + // The private host detail is an EXTRA read on a page that already has its |
| + // answer: a bucket that won't list the boxes costs the operator the |
| + // per-host block, not the whole fleet page, so this degrades with a log |
| + // line rather than turning a readable page into a 500. |
| + hosts, err := HostRows(r.Context(), s.cfg.Releases, time.Now()) |
| + if err != nil { |
| + log.Printf("console: fleet hosts: %v", err) |
| + hosts = nil |
| + } |
| + p := page{Title: "Fleet status", Address: addr, CSRF: raw, Fleet: f, Hosts: hosts} |
| s.render(w, http.StatusOK, "fleet", p) |
| } |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 99a94cb4d..9c5d054a9 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -1161,6 +1161,133 @@ func TestFleetKeylessReadable(t *testing.T) { |
| } |
| } |
| +// seedHostBoxes writes one fresh and one long-dark box snapshot, both |
| +// carrying a private Metrics block, and publishes the merged feed over them — |
| +// the exact pair /fleet's Hosts detail has to tell apart. |
| +func seedHostBoxes(t *testing.T, fx *consoleFixture) { |
| + t.Helper() |
| + ctx := context.Background() |
| + now := time.Now() |
| + for _, b := range []status.Box{ |
| + { |
| + GeneratedAt: now, Label: "ada-stargazing", Region: "eu-west-1", |
| + Metrics: &status.BoxMetrics{ |
| + MemTotalBytes: pi64(2 << 30), MemAvailBytes: pi64(1 << 30), |
| + Load1: p(0.42), DiskFreeBytes: pi64(5 << 30), |
| + Release: "abc1234", Tunnel: "connected", |
| + }, |
| + }, |
| + { |
| + GeneratedAt: now.Add(-time.Hour), Label: "grace-kayaking", Region: "eu-west-2", |
| + Metrics: &status.BoxMetrics{ |
| + MemTotalBytes: pi64(9 << 30), MemAvailBytes: pi64(7 << 30), |
| + Load1: p(8.25), DiskFreeBytes: pi64(3 << 30), |
| + Release: "beefcaf", Tunnel: "connected", |
| + }, |
| + }, |
| + } { |
| + if err := status.WriteBox(ctx, fx.rel, b); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + f, err := status.BuildFleet(ctx, fx.rel, nil, now, status.SnapshotPeriod) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := status.PublishFleet(ctx, fx.rel, f); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// TestFleetHostsDetailSuppressesStaleMetrics is Task 7's core rule, on the one |
| +// surface allowed to render raw numbers at all: the operator fleet page shows |
| +// a live host's release, tunnel state, memory, load and free disk — and shows |
| +// an unreachable host as unreachable, with how long it has been dark and NOT |
| +// ONE of its last happy numbers. A dead box's final reading rendered as a |
| +// current figure is the failure this whole task exists to prevent. |
| +func TestFleetHostsDetailSuppressesStaleMetrics(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedHostBoxes(t, fx) |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, _, body := get(t, c, fx.ts.URL+"/fleet") |
| + if code != 200 { |
| + t.Fatalf("/fleet = %d: %s", code, body) |
| + } |
| + for _, want := range []string{ |
| + "Hosts", "ada-stargazing", "abc1234", "connected", |
| + "1.0 GB", "2.0 GB", "0.42", "5.0 GB", // the fresh box's raw readings |
| + "grace-kayaking", "unreachable", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("/fleet hosts detail missing %q in:\n%s", want, body) |
| + } |
| + } |
| + for _, leaked := range []string{"beefcaf", "8.25", "7.0 GB", "9.0 GB", "3.0 GB"} { |
| + if strings.Contains(body, leaked) { |
| + t.Fatalf("unreachable host's stale reading %q rendered — a dark box must show no numbers:\n%s", leaked, body) |
| + } |
| + } |
| +} |
| + |
| +// TestFleetHostsDegradeWhenUnreadable: the Hosts detail is an extra read on a |
| +// page that already had a job. If the boxes can't be listed, the page still |
| +// renders the merged feed it could read — metrics are never the reason /fleet |
| +// 500s. |
| +func TestFleetHostsDegradeWhenUnreadable(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedFleetJSON(t, fx) |
| + fx.srv.cfg.Releases = listFailStore{fx.rel} |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, _, body := get(t, c, fx.ts.URL+"/fleet") |
| + if code != 200 || !strings.Contains(body, "Fleet status") || !strings.Contains(body, "ada-stargazing") { |
| + t.Fatalf("/fleet with an unlistable bucket = %d, want the fleet feed still rendered:\n%s", code, body) |
| + } |
| +} |
| + |
| +// TestFleetHostsEscapesHostSuppliedStrings: Release and Tunnel are strings a |
| +// BOX writes into its own snapshot — the console reads them, it does not |
| +// mint them — so a compromised or buggy host must not be able to put markup |
| +// on an operator's page. html/template escapes them because they are plain |
| +// strings all the way through (never template.HTML, unlike icons.go's |
| +// vendored SVG); this test is what keeps that true. |
| +func TestFleetHostsEscapesHostSuppliedStrings(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := status.WriteBox(context.Background(), fx.rel, status.Box{ |
| + GeneratedAt: time.Now(), Label: "leo-juggling", Region: "eu-west-1", |
| + Metrics: &status.BoxMetrics{Release: "<script>alert(1)</script>", Tunnel: "connected"}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, _, body := get(t, c, fx.ts.URL+"/fleet") |
| + if code != 200 { |
| + t.Fatalf("/fleet = %d: %s", code, body) |
| + } |
| + if strings.Contains(body, "<script>alert(1)</script>") { |
| + t.Fatalf("a host-supplied release string reached the page unescaped:\n%s", body) |
| + } |
| + if !strings.Contains(body, "<script>alert(1)</script>") { |
| + t.Fatalf("escaped release string not rendered at all:\n%s", body) |
| + } |
| +} |
| + |
| +// listFailStore is a store whose List always fails — everything else passes |
| +// through, so fleet.json still reads and only the boxes sweep breaks. |
| +type listFailStore struct{ release.Store } |
| + |
| +func (listFailStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + return nil, errors.New("bucket list refused") |
| +} |
| + |
| // TestFleetMetricsOperatorOnly is Task 11's gate, mirroring TestFleetOperatorOnly |
| // for /fleet/metrics: signed out and signed in-but-not-operator both get |
| // exactly the same 404 an unrouted URL gets; an operator gets 200 with a |
diff --git a/internal/console/templates/fleet.html b/internal/console/templates/fleet.html| index f82bcf944..f125a2dc4 100644 |
| --- a/internal/console/templates/fleet.html |
| +++ b/internal/console/templates/fleet.html |
| @@ -91,4 +91,40 @@ |
| {{else}} |
| <p>No snapshots yet — the fleet status writer hasn't published <code>status/fleet.json</code> on this deployment.</p> |
| {{end}} |
| + |
| +{{if .Hosts}} |
| +<h2>Hosts</h2> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr> |
| + <th>Host</th><th>Region</th><th>Release</th><th>Tunnel</th> |
| + <th>Memory free</th><th>Load</th><th>Disk free</th><th>Last report</th> |
| + </tr> |
| + {{range .Hosts}} |
| + <tr> |
| + <td>{{.Label}}</td> |
| + <td>{{emptyOrDash .Region}}</td> |
| + {{if .Metrics}} |
| + <td>{{emptyOrDash .Metrics.Release}}</td> |
| + <td>{{emptyOrDash .Metrics.Tunnel}}</td> |
| + <td>{{bytesOrDash .Metrics.MemAvailBytes}} / {{bytesOrDash .Metrics.MemTotalBytes}}</td> |
| + <td>{{loadOrDash .Metrics.Load1}}</td> |
| + <td>{{bytesOrDash .Metrics.DiskFreeBytes}}</td> |
| + <td>{{fmtTime .LastSeen}}</td> |
| + {{else if .Stale}} |
| + {{/* An unreachable host says how long it has been dark and nothing else: |
| + its last readings are history, and a history rendered in a "now" |
| + column is how an outage gets missed. */}} |
| + <td colspan="5"><span class="chip chip-down">unreachable</span> last heartbeat {{.Dark}} ago</td> |
| + <td>{{fmtTime .LastSeen}}</td> |
| + {{else}} |
| + <td colspan="5" class="small">no metrics block — this host predates raw metric reporting</td> |
| + <td>{{fmtTime .LastSeen}}</td> |
| + {{end}} |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +<p class="small">Read from <code>status/boxes/</code> in the deployment bucket, which is not public — these are raw figures, unlike the aggregate feed above, and this page is why they stay behind a session.</p> |
| +{{end}} |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/view.go b/internal/console/view.go| index 2f34e51ed..25aab922a 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -11,6 +11,7 @@ import ( |
| "errors" |
| "os" |
| "sort" |
| + "strconv" |
| "strings" |
| "sync" |
| "time" |
| @@ -447,6 +448,89 @@ func FleetView(ctx context.Context, s release.Store) (*status.Fleet, error) { |
| return &f, nil |
| } |
| +// HostRow is one box's PRIVATE detail line on the operator fleet page: the |
| +// raw block status.Box carries and fleet.json deliberately never publishes |
| +// (internal/status's package comment), plus whether the box is still |
| +// reporting at all. |
| +// |
| +// Metrics is nil for a box that is STALE as well as for one that never |
| +// reported any — suppression happens here, in the view model, not in the |
| +// template. That is the point: an unreachable host's last happy numbers must |
| +// never reach the page, and the only way to be sure of that is for the page |
| +// to never be handed them. A template condition would be one careless |
| +// {{if}}-rearrangement away from printing a dead box's memory as a current |
| +// figure. |
| +type HostRow struct { |
| + Label string |
| + Region string |
| + // Stale is status.BoxStale — the fleet's one staleness rule, measured |
| + // against status.SnapshotPeriod, so this table and the published boxes |
| + // table above it can never disagree about which host is dark. |
| + Stale bool |
| + // LastSeen is the snapshot's own GeneratedAt and Dark is how long ago that |
| + // was, pre-rendered ("1h2m"). Dark is set only for a stale row: it is the |
| + // one thing an unreachable host is allowed to say about itself. |
| + LastSeen time.Time |
| + Dark string |
| + Metrics *status.BoxMetrics |
| +} |
| + |
| +// HostRows reads every per-box snapshot (status.LoadBoxes — the same loader |
| +// and the same skip-corrupt rule the public merge uses) and turns it into the |
| +// operator page's private host table, sorted by label so the order is stable |
| +// across refreshes. |
| +// |
| +// A nil store is not an error: a deployment configured without a bucket |
| +// handle simply has no host detail to show, and the fleet page renders |
| +// without the section rather than failing. Same posture for the caller on a |
| +// read error — see handleFleet. |
| +func HostRows(ctx context.Context, s release.Store, now time.Time) ([]HostRow, error) { |
| + if s == nil { |
| + return nil, nil |
| + } |
| + boxes, err := status.LoadBoxes(ctx, s) |
| + if err != nil { |
| + return nil, err |
| + } |
| + rows := make([]HostRow, 0, len(boxes)) |
| + for _, b := range boxes { |
| + row := HostRow{ |
| + Label: b.Label, |
| + Region: b.Region, |
| + Stale: status.BoxStale(b, now, status.SnapshotPeriod), |
| + LastSeen: b.GeneratedAt, |
| + Metrics: b.Metrics, |
| + } |
| + if row.Stale { |
| + row.Dark = humanAge(now.Sub(b.GeneratedAt)) |
| + row.Metrics = nil // an unreachable host shows no numbers at all |
| + } |
| + rows = append(rows, row) |
| + } |
| + sort.Slice(rows, func(i, j int) bool { return rows[i].Label < rows[j].Label }) |
| + return rows, nil |
| +} |
| + |
| +// humanAge renders how long a box has been dark at the resolution an operator |
| +// actually reads it at: minutes up to an hour, then hours and minutes, then |
| +// days. Never seconds — a snapshot is only ever called stale after three |
| +// missed five-minute ticks, so second-level precision would be false detail — |
| +// and never a negative age: a box whose clock runs ahead of the console's |
| +// reads as "0m", not as a time traveller. |
| +func humanAge(d time.Duration) string { |
| + if d < 0 { |
| + d = 0 |
| + } |
| + switch { |
| + case d < time.Hour: |
| + return strconv.Itoa(int(d.Minutes())) + "m" |
| + case d < 24*time.Hour: |
| + return strconv.Itoa(int(d.Hours())) + "h" + strconv.Itoa(int(d.Minutes())%60) + "m" |
| + default: |
| + return strconv.Itoa(int(d.Hours())/24) + "d" + strconv.Itoa(int(d.Hours())%24) + "h" |
| + } |
| +} |
| + |
| // versionsCap bounds the app page's release list. |
| const versionsCap = 20 |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 8316313ca..09f968a6a 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -540,6 +540,16 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| // has other env-reading tests; a plain map/func seam avoids that coupling |
| // entirely. |
| func resolveStatusBucket(getenv func(string) string) string { |
| + return ResolveStatusBucket(getenv) |
| +} |
| + |
| +// ResolveStatusBucket is resolveStatusBucket for callers outside this package |
| +// — `carlos status -live` reads the boxes an agent wrote, and it must land on |
| +// the SAME bucket the agent's status loop writes to, including the |
| +// CARLOS_DEPLOYMENT_BUCKET fallback. Exported as a second name over one |
| +// implementation rather than renamed at every internal call site, so the |
| +// defaulting rule documented above stays in exactly one function. |
| +func ResolveStatusBucket(getenv func(string) string) string { |
| if b := getenv("CARLOS_STATUS_BUCKET"); b != "" { |
| return b |
| } |
| @@ -707,8 +717,10 @@ func statusTick(ctx context.Context, s release.Store, reg *registry.Registry, p |
| return nil |
| } |
| - const fleetPeriod = 5 * time.Minute // matches statusLoop's own tick cadence |
| - fleet, err := status.BuildFleet(ctx, s, routes, now, fleetPeriod) |
| + // status.SnapshotPeriod is this loop's own tick cadence, named once in the |
| + // status package so the console's private host table and `carlos status |
| + // -live` measure staleness against the same period this publish does. |
| + fleet, err := status.BuildFleet(ctx, s, routes, now, status.SnapshotPeriod) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/status/fleet.go b/internal/status/fleet.go| index 7f69d8e92..5dd0c8938 100644 |
| --- a/internal/status/fleet.go |
| +++ b/internal/status/fleet.go |
| @@ -95,9 +95,52 @@ var capacityRank = map[string]int{ |
| // without this helper. |
| func intPtr(v int) *int { return &v } |
| +// SnapshotPeriod is how often every box republishes its own |
| +// status/boxes/<label>.json (the agent's status loop tick, 5 minutes + |
| +// jitter). It lives here rather than at each reader because it is the unit |
| +// BoxStale measures staleness in: the console's private host detail and |
| +// `carlos status -live` both have to answer "is this box dark?" without an |
| +// agent's local const to borrow, and a reader that guessed a different period |
| +// would call a box unreachable at a different moment than the published |
| +// boxes table does. |
| +const SnapshotPeriod = 5 * time.Minute |
| + |
| +// staleFactor is how many missed snapshot periods make a box stale. Three |
| +// consecutive misses: past any single hiccup (a slow S3 write, one skipped |
| +// tick), and the point at which a box has plainly stopped self-reporting. |
| +// One constant, one rule — BuildFleet's published FleetBox.Stale and every |
| +// private reader go through BoxStale below, so nothing can drift into calling |
| +// the same box fresh on one page and dark on another. |
| +const staleFactor = 3 |
| + |
| +// BoxStale is the fleet's one staleness rule: a snapshot older than |
| +// staleFactor×period means the box stopped reporting. Every caller — the |
| +// public merge, the console's private host table, the CLI — asks this |
| +// function, never its own subtraction. |
| +// |
| +// Callers that render must treat true as "this box's numbers are history": |
| +// per the design spec an unreachable host shows how long it has been dark and |
| +// NOTHING else, never its last happy readings. |
| +func BoxStale(b Box, now time.Time, period time.Duration) bool { |
| + return now.Sub(b.GeneratedAt) > staleFactor*period |
| +} |
| + |
| +// LoadBoxes reads every per-box snapshot in the bucket — the same read |
| +// BuildFleet does, exported for the private readers (the console's operator |
| +// fleet page, `carlos status -live`) so there is exactly ONE parser of the |
| +// status/boxes/ shape and one skip-corrupt rule, rather than a second loader |
| +// that could disagree about which snapshots count. |
| +// |
| +// Unlike the merged feed, what comes back here still carries Box.Metrics: raw |
| +// numbers that must reach a private surface only (bucket credentials or a |
| +// session-gated operator page), never fleet.json. See the package comment. |
| +func LoadBoxes(ctx context.Context, s release.Store) ([]Box, error) { |
| + return loadBoxes(ctx, s) |
| +} |
| + |
| // BuildFleet merges every status/boxes/*.json (stale = GeneratedAt older |
| -// than 3×period), folds the local registry's routes into apps/services, |
| -// and never invents a number a snapshot didn't carry. |
| +// than staleFactor×period — see BoxStale), folds the local registry's routes |
| +// into apps/services, and never invents a number a snapshot didn't carry. |
| func BuildFleet(ctx context.Context, s release.Store, routes []registry.Route, now time.Time, period time.Duration) (*Fleet, error) { |
| boxes, err := loadBoxes(ctx, s) |
| if err != nil { |
| @@ -112,7 +155,7 @@ func BuildFleet(ctx context.Context, s release.Store, routes []registry.Route, n |
| // fresh boxes. |
| stale := make([]bool, len(boxes)) |
| for i, b := range boxes { |
| - stale[i] = now.Sub(b.GeneratedAt) > 3*period |
| + stale[i] = BoxStale(b, now, period) |
| f.Boxes = append(f.Boxes, FleetBox{ |
| Label: b.Label, |
| Region: b.Region, |
diff --git a/internal/status/status_test.go b/internal/status/status_test.go| index b3d907a25..142414b80 100644 |
| --- a/internal/status/status_test.go |
| +++ b/internal/status/status_test.go |
| @@ -820,3 +820,77 @@ func TestFleetBoxCarriesAdoption(t *testing.T) { |
| f.Boxes[0].AdoptionRunning) |
| } |
| } |
| + |
| +// ---- Task 7: the exported box loader and the one staleness rule ------------- |
| + |
| +// TestLoadBoxesAndStaleness pins the two things the console's private host |
| +// detail and `carlos status -live` both read the bucket through: LoadBoxes |
| +// returns every snapshot WITH its private Metrics block intact (the merged |
| +// public feed drops it — this loader must not), and BoxStale answers the same |
| +// "older than 3 periods" question BuildFleet's own boxes table answers, so the |
| +// two renderings can never disagree about which host is dark. |
| +func TestLoadBoxesAndStaleness(t *testing.T) { |
| + s := teststore(t) |
| + ctx := context.Background() |
| + now := time.Now() |
| + fresh := status.Box{GeneratedAt: now, Label: "ada-stargazing", |
| + Metrics: &status.BoxMetrics{Release: "abc1234", Tunnel: "connected"}} |
| + dark := status.Box{GeneratedAt: now.Add(-time.Hour), Label: "grace-kayaking"} |
| + for _, b := range []status.Box{fresh, dark} { |
| + if err := status.WriteBox(ctx, s, b); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + boxes, err := status.LoadBoxes(ctx, s) |
| + if err != nil || len(boxes) != 2 { |
| + t.Fatalf("LoadBoxes: %v, %d boxes", err, len(boxes)) |
| + } |
| + for _, b := range boxes { |
| + wantStale := b.Label == "grace-kayaking" |
| + if got := status.BoxStale(b, now, 5*time.Minute); got != wantStale { |
| + t.Fatalf("%s: stale = %v, want %v", b.Label, got, wantStale) |
| + } |
| + if b.Label == "ada-stargazing" { |
| + if b.Metrics == nil || b.Metrics.Release != "abc1234" || b.Metrics.Tunnel != "connected" { |
| + t.Fatalf("LoadBoxes dropped the private metrics block: %+v", b.Metrics) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestBoxStaleMatchesFleetBoxStale is the anti-drift assertion: whatever |
| +// BoxStale says about a box must be exactly what BuildFleet publishes in that |
| +// box's FleetBox.Stale — one rule, one constant, two renderings. |
| +func TestBoxStaleMatchesFleetBoxStale(t *testing.T) { |
| + s := teststore(t) |
| + ctx := context.Background() |
| + now := time.Now() |
| + const period = 5 * time.Minute |
| + for _, b := range []status.Box{ |
| + boxFixture("just-now", "plenty", now), |
| + boxFixture("two-periods", "plenty", now.Add(-2*period)), |
| + boxFixture("exactly-three", "plenty", now.Add(-3*period)), |
| + boxFixture("long-dark", "plenty", now.Add(-time.Hour)), |
| + } { |
| + if err := status.WriteBox(ctx, s, b); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + f, err := status.BuildFleet(ctx, s, nil, now, period) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + boxes, err := status.LoadBoxes(ctx, s) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + byLabel := map[string]bool{} |
| + for _, fb := range f.Boxes { |
| + byLabel[fb.Label] = fb.Stale |
| + } |
| + for _, b := range boxes { |
| + if got, want := status.BoxStale(b, now, period), byLabel[b.Label]; got != want { |
| + t.Errorf("%s: BoxStale = %v, FleetBox.Stale = %v — the rule has drifted", b.Label, got, want) |
| + } |
| + } |
| +} |
diff --git a/statuslive_test.go b/statuslive_test.go| new file mode 100644 |
| index 000000000..028bae66d |
| --- /dev/null |
| +++ b/statuslive_test.go |
| @@ -0,0 +1,127 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +func f64(v float64) *float64 { return &v } |
| +func i64(v int64) *int64 { return &v } |
| + |
| +// TestPrintLiveBoxesSuppressesStaleMetrics is the CLI half of the rule the |
| +// console page enforces: a host that stopped reporting prints as UNREACHABLE |
| +// with how long it has been dark, and NONE of its last readings. The operator |
| +// running this has bucket credentials, so the numbers are theirs to see — the |
| +// point is that a dead box's numbers are not a current answer to anything. |
| +func TestPrintLiveBoxesSuppressesStaleMetrics(t *testing.T) { |
| + now := time.Now() |
| + boxes := []status.Box{ |
| + { |
| + GeneratedAt: now, Label: "ada-stargazing", Region: "eu-west-1", |
| + Metrics: &status.BoxMetrics{ |
| + MemTotalBytes: i64(2 << 30), MemAvailBytes: i64(1 << 30), |
| + Load1: f64(0.42), DiskFreeBytes: i64(5 << 30), |
| + Release: "abc1234", Tunnel: "connected", |
| + }, |
| + }, |
| + { |
| + GeneratedAt: now.Add(-90 * time.Minute), Label: "grace-kayaking", Region: "eu-west-2", |
| + Metrics: &status.BoxMetrics{ |
| + MemTotalBytes: i64(9 << 30), MemAvailBytes: i64(7 << 30), |
| + Load1: f64(8.25), DiskFreeBytes: i64(3 << 30), |
| + Release: "beefcaf", Tunnel: "connected", |
| + }, |
| + }, |
| + } |
| + var buf bytes.Buffer |
| + printLiveBoxes(&buf, boxes, now) |
| + out := buf.String() |
| + |
| + for _, want := range []string{ |
| + "ada-stargazing", "eu-west-1", "release=abc1234", "tunnel=connected", |
| + "mem=1.0GB/2.0GB", "load=0.42", "disk-free=5.0GB", |
| + "grace-kayaking", "UNREACHABLE", "1h30m0s", |
| + } { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("missing %q in:\n%s", want, out) |
| + } |
| + } |
| + for _, leaked := range []string{"beefcaf", "8.25", "7.0GB", "9.0GB", "3.0GB"} { |
| + if strings.Contains(out, leaked) { |
| + t.Fatalf("stale host's reading %q printed:\n%s", leaked, out) |
| + } |
| + } |
| +} |
| + |
| +// TestPrintLiveBoxesPreUpgradeHost: a box running code older than the metrics |
| +// block is fresh and fine — it says so, in the same columns, rather than |
| +// printing a row of fabricated zeros or dropping out of the listing. |
| +func TestPrintLiveBoxesPreUpgradeHost(t *testing.T) { |
| + now := time.Now() |
| + var buf bytes.Buffer |
| + printLiveBoxes(&buf, []status.Box{ |
| + {GeneratedAt: now, Label: "alan-foraging"}, // no metrics, no region |
| + {GeneratedAt: now, Label: "iris-sailing", Metrics: &status.BoxMetrics{Tunnel: "reconnecting"}}, |
| + }, now) |
| + out := buf.String() |
| + if !strings.Contains(out, "alan-foraging") || !strings.Contains(out, "no metrics block") { |
| + t.Fatalf("pre-upgrade host row wrong:\n%s", out) |
| + } |
| + // A metrics block with only some fields known dashes the rest — never a |
| + // zero, the same "unknown is not a number" rule the status package holds. |
| + if !strings.Contains(out, "tunnel=reconnecting") || !strings.Contains(out, "mem=-/-") || |
| + !strings.Contains(out, "load=-") || !strings.Contains(out, "disk-free=-") || |
| + !strings.Contains(out, "release=-") { |
| + t.Fatalf("partial metrics row wrong:\n%s", out) |
| + } |
| + for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") { |
| + if !strings.HasPrefix(line, "alan-foraging") && !strings.HasPrefix(line, "iris-sailing") { |
| + t.Fatalf("row does not start with its label (alignment broken):\n%s", out) |
| + } |
| + } |
| +} |
| + |
| +// TestPrintLiveBoxesSortsByLabel: the listing is stable across runs, so a |
| +// diff between two invocations is a change in the fleet, not in list order. |
| +func TestPrintLiveBoxesSortsByLabel(t *testing.T) { |
| + now := time.Now() |
| + var buf bytes.Buffer |
| + printLiveBoxes(&buf, []status.Box{ |
| + {GeneratedAt: now, Label: "zoe-welding"}, |
| + {GeneratedAt: now, Label: "ada-stargazing"}, |
| + }, now) |
| + if a, z := strings.Index(buf.String(), "ada-"), strings.Index(buf.String(), "zoe-"); a > z { |
| + t.Fatalf("rows are not label-sorted:\n%s", buf.String()) |
| + } |
| +} |
| + |
| +func TestHumanBytesAndFloat(t *testing.T) { |
| + for _, c := range []struct { |
| + in *int64 |
| + want string |
| + }{ |
| + {nil, "-"}, |
| + {i64(0), "0B"}, |
| + {i64(512), "512B"}, |
| + {i64(1536), "1.5KB"}, |
| + {i64(3 << 20), "3.0MB"}, |
| + {i64(2 << 30), "2.0GB"}, |
| + } { |
| + if got := humanBytes(c.in); got != c.want { |
| + t.Errorf("humanBytes(%v) = %q, want %q", c.in, got, c.want) |
| + } |
| + } |
| + if got := humanFloat(nil); got != "-" { |
| + t.Errorf("humanFloat(nil) = %q, want -", got) |
| + } |
| + if got := humanFloat(f64(1.5)); got != "1.50" { |
| + t.Errorf("humanFloat(1.5) = %q, want 1.50", got) |
| + } |
| + if got := orDash(""); got != "-" { |
| + t.Errorf("orDash(\"\") = %q, want -", got) |
| + } |
| +} |