status: a box reports its platform, its system channel and its last self-update outcome
Three optional fields on the private metrics block; the public fleet feed does not carry them, as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
5 files changed,
+152
−6
internal/host/agent.go+6 −0internal/host/systemprobe.go+30 −0internal/host/systemprobe_test.go+37 −0internal/status/snapshot.go+29 −6internal/status/snapshot_test.go+50 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 130b6e1ce..81b996fe5 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -91,6 +91,8 @@ |
| // free text chosen by the operator, never derived from cloud metadata — |
| // the public commitment is a human-readable region, not a specific |
| // datacenter. |
| +// - CARLOS_SYSTEM_CHANNEL — platform channel this box follows for |
| +// self-update, canary|stable (default stable). |
| // - STATUS_HOST / STATUS_PUBLIC — NOT read anywhere in this file. They |
| // gate whether ops/tofu fronts the bucket's published status/ prefix |
| // with a public hostname; recorded here only so the fleet status |
| @@ -155,6 +157,7 @@ import ( |
| "os/exec" |
| "path" |
| "path/filepath" |
| + "runtime" |
| "sort" |
| "strings" |
| "sync" |
| @@ -2316,6 +2319,9 @@ func statusLoop(reg *registry.Registry) { |
| Load1: readLoad1(), |
| DiskFreeBytes: readDiskFreeBytes(), |
| Release: version.Build, |
| + Platform: runtime.GOOS + "-" + runtime.GOARCH, |
| + SystemChannel: systemChannel(os.Getenv), |
| + System: systemStateProbe(release.OptDir()), |
| Tunnel: tunnelState(), |
| // The edge's probe table, read WHOLE every tick (never merged |
| // with the last one), so a route this box has stopped probing |
diff --git a/internal/host/systemprobe.go b/internal/host/systemprobe.go| new file mode 100644 |
| index 000000000..6d088dae3 |
| --- /dev/null |
| +++ b/internal/host/systemprobe.go |
| @@ -0,0 +1,30 @@ |
| +package host |
| + |
| +import ( |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// systemChannelEnv is the box's platform channel: canary or stable. Set in |
| +// host.env, read through the getenv seam like every other CARLOS_* value; |
| +// the ROOT apply reads it from its own unit environment, never from the |
| +// agent's request (spec §6 step 2). |
| +const systemChannelEnv = "CARLOS_SYSTEM_CHANNEL" |
| + |
| +func systemChannel(getenv func(string) string) string { |
| + if c := strings.TrimSpace(getenv(systemChannelEnv)); c != "" { |
| + return c |
| + } |
| + return "stable" |
| +} |
| + |
| +// systemStateProbe is root's result.json as the heartbeat carries it, or nil |
| +// when this box has never been asked to update. |
| +func systemStateProbe(optDir string) *status.SystemState { |
| + r, err := readSystemResult(optDir) |
| + if err != nil || r == nil { |
| + return nil |
| + } |
| + return &status.SystemState{Channel: r.Channel, Version: r.Version, PromotedAt: r.PromotedAt, Outcome: r.Outcome, Reason: r.Reason, At: r.At} |
| +} |
diff --git a/internal/host/systemprobe_test.go b/internal/host/systemprobe_test.go| new file mode 100644 |
| index 000000000..0dbcb019a |
| --- /dev/null |
| +++ b/internal/host/systemprobe_test.go |
| @@ -0,0 +1,37 @@ |
| +package host |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestSystemChannelDefaultsToStable(t *testing.T) { |
| + if got := systemChannel(func(string) string { return "" }); got != "stable" { |
| + t.Fatalf("default = %q", got) |
| + } |
| + if got := systemChannel(func(k string) string { |
| + if k == systemChannelEnv { |
| + return " canary " |
| + } |
| + return "" |
| + }); got != "canary" { |
| + t.Fatalf("env = %q", got) |
| + } |
| +} |
| + |
| +func TestSystemStateProbeReadsRootsResult(t *testing.T) { |
| + opt := t.TempDir() |
| + if got := systemStateProbe(opt); got != nil { |
| + t.Fatalf("no result.json → %+v, want nil", got) |
| + } |
| + root := systemRootDir(opt) |
| + os.MkdirAll(root, 0o755) |
| + at := time.Date(2026, 9, 6, 9, 0, 0, 0, time.UTC) |
| + writeJSONDurable(filepath.Join(root, "result.json"), systemResult{Channel: "stable", Version: "v3", PromotedAt: at, Outcome: "applied", At: at}, 0o644) |
| + got := systemStateProbe(opt) |
| + if got == nil || got.Version != "v3" || got.Outcome != "applied" || !got.PromotedAt.Equal(at) { |
| + t.Fatalf("probe = %+v", got) |
| + } |
| +} |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index a69318d6a..ec8c5b0d0 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -97,6 +97,13 @@ type Probes struct { |
| // unknown/not-applicable for both, never a guess. |
| Release string |
| Tunnel string |
| + // Platform is runtime.GOOS-runtime.GOARCH, so the console knows which |
| + // artifacts a platform promote must mirror. SystemChannel is the box's |
| + // CARLOS_SYSTEM_CHANNEL, so the console knows which boxes a promote |
| + // reaches. System is root's last self-update outcome (spec §5, §6). |
| + Platform string |
| + SystemChannel string |
| + System *SystemState |
| // Upstreams is this box's edge probe table — route host -> per-upstream |
| // state — read WHOLE from the prober on every tick (internal/edge's |
| // Edge.UpstreamStates), never accumulated across ticks. That rebuild is |
| @@ -145,17 +152,32 @@ type UpstreamState struct { |
| LastError string `json:"last_error,omitempty"` // dial vs status, for the log/dashboard |
| } |
| +// SystemState is the last platform self-update outcome root recorded on the |
| +// box, published so `carlos system status` can show a rolled-back box with |
| +// its reason within one heartbeat. |
| +type SystemState struct { |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` |
| + PromotedAt time.Time `json:"promoted_at"` |
| + Outcome string `json:"outcome"` |
| + Reason string `json:"reason,omitempty"` |
| + At time.Time `json:"at"` |
| +} |
| + |
| // BoxMetrics is the raw, PRIVATE block: real numbers, never banded. It lives |
| // in status/boxes/<label>.json — which only fleet-status machinery and |
| // operators read — and is deliberately NOT copied into FleetBox, so the |
| // publicly served fleet.json keeps the banded-words-only commitment. See the |
| // package doc comment for why this block is safe to exist at all. |
| type BoxMetrics struct { |
| - MemTotalBytes *int64 `json:"mem_total_bytes,omitempty"` |
| - MemAvailBytes *int64 `json:"mem_avail_bytes,omitempty"` |
| - Load1 *float64 `json:"load1,omitempty"` |
| - DiskFreeBytes *int64 `json:"disk_free_bytes,omitempty"` |
| - Release string `json:"release,omitempty"` // running binary's version.Build |
| + MemTotalBytes *int64 `json:"mem_total_bytes,omitempty"` |
| + MemAvailBytes *int64 `json:"mem_avail_bytes,omitempty"` |
| + Load1 *float64 `json:"load1,omitempty"` |
| + DiskFreeBytes *int64 `json:"disk_free_bytes,omitempty"` |
| + Release string `json:"release,omitempty"` // running binary's version.Build |
| + Platform string `json:"platform,omitempty"` // runtime.GOOS-runtime.GOARCH |
| + SystemChannel string `json:"system_channel,omitempty"` // CARLOS_SYSTEM_CHANNEL |
| + System *SystemState `json:"system,omitempty"` // last self-update outcome |
| // InstanceID is the cloud machine this box IS — read from the instance |
| // metadata service by the box itself (status.InstanceID). "" means no |
| // cloud machine owns it: a Pi, a laptop, someone else's hardware. |
| @@ -541,12 +563,13 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| // alias) has nothing to say, not something empty to say. |
| if p.MemTotalBytes != nil || p.MemAvailBytes != nil || p.Load1 != nil || |
| p.DiskFreeBytes != nil || p.Release != "" || p.Tunnel != "" || |
| - p.InstanceID != "" || |
| + p.InstanceID != "" || p.Platform != "" || p.SystemChannel != "" || p.System != nil || |
| len(routeVersions) > 0 || len(aliasReadings) > 0 || len(p.Upstreams) > 0 { |
| box.Metrics = &BoxMetrics{ |
| MemTotalBytes: p.MemTotalBytes, MemAvailBytes: p.MemAvailBytes, |
| Load1: p.Load1, DiskFreeBytes: p.DiskFreeBytes, |
| Release: p.Release, Tunnel: p.Tunnel, |
| + Platform: p.Platform, SystemChannel: p.SystemChannel, System: p.System, |
| InstanceID: p.InstanceID, |
| RouteVersions: routeVersions, |
| Aliases: aliasReadings, |
diff --git a/internal/status/snapshot_test.go b/internal/status/snapshot_test.go| new file mode 100644 |
| index 000000000..e9518cdeb |
| --- /dev/null |
| +++ b/internal/status/snapshot_test.go |
| @@ -0,0 +1,50 @@ |
| +package status |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestBoxMetricsCarryThePlatformSelfUpdateState proves Task 5's three new |
| +// Probes fields (Platform, SystemChannel, System) reach Box.Metrics, reach |
| +// the wire under their json names, count toward the "something is known" |
| +// emptiness guard on their own, and — like every other Metrics field — never |
| +// reach the public fleet feed. |
| +func TestBoxMetricsCarryThePlatformSelfUpdateState(t *testing.T) { |
| + at := time.Date(2026, 9, 6, 9, 0, 0, 0, time.UTC) |
| + p := Probes{Release: "v1", Platform: "linux-arm64", SystemChannel: "canary", |
| + System: &SystemState{Channel: "canary", Version: "v2", PromotedAt: at, Outcome: "rolled-back", Reason: "gate", At: at}} |
| + b := CollectBox(nil, p, at) |
| + if b.Metrics == nil || b.Metrics.Platform != "linux-arm64" || b.Metrics.SystemChannel != "canary" || b.Metrics.System == nil || b.Metrics.System.Outcome != "rolled-back" { |
| + t.Fatalf("metrics = %+v", b.Metrics) |
| + } |
| + out, _ := json.Marshal(b) |
| + for _, want := range []string{`"platform":"linux-arm64"`, `"system_channel":"canary"`, `"outcome":"rolled-back"`} { |
| + if !strings.Contains(string(out), want) { |
| + t.Errorf("json lacks %s: %s", want, out) |
| + } |
| + } |
| + // A box with only the new fields is not "empty". |
| + b2 := CollectBox(nil, Probes{Platform: "linux-arm64"}, at) |
| + if b2.Metrics == nil { |
| + t.Fatal("platform alone must produce a metrics block") |
| + } |
| + // The public fleet feed still never carries the metrics block. |
| + s := release.NewDirStore(t.TempDir()) |
| + if err := WriteBox(context.Background(), s, b); err != nil { |
| + t.Fatal(err) |
| + } |
| + f, err := BuildFleet(context.Background(), s, nil, at, 5*time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fb, _ := json.Marshal(f) |
| + if strings.Contains(string(fb), "system_channel") || strings.Contains(string(fb), "rolled-back") { |
| + t.Fatalf("fleet.json leaked the private metrics: %s", fb) |
| + } |
| +} |