carlos / platform Public

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
Paul Campbell pushed by paul@keymail.dev 9c62d090c6a6272a9ed829ccaebfb90712773305 parent b11192f
5 files changed, +152 −6
  • internal/host/agent.go +6 −0
  • internal/host/systemprobe.go +30 −0
  • internal/host/systemprobe_test.go +37 −0
  • internal/status/snapshot.go +29 −6
  • internal/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)
+ }
+}