carlos / platform Public

status+console: staged_version surfaces through the instances API (#78)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a146375c50bce2f9260afa1ae554b99e80a7594f parent 7bf188d
7 files changed, +276 −26
  • apimode.go +7 −0
  • instances_cli.go +2 −2
  • internal/console/apiinstances.go +62 −4
  • internal/console/apiinstances_test.go +70 −0
  • internal/status/contract_test.go +35 −14
  • internal/status/snapshot.go +34 −6
  • internal/status/status_test.go +66 −0
diff --git a/apimode.go b/apimode.go
index ec697f2f3..875edf27f 100644
--- a/apimode.go
+++ b/apimode.go
@@ -859,6 +859,13 @@ type apiInstanceRecord struct {
Channel string `json:"channel"`
CreatedAt string `json:"created_at"`
CreatedBy string `json:"created_by"`
+ // StagedVersion is the console's additive staged_version join (Task 8,
+ // issue #78): whichever version a box's adopt pass last reported for
+ // this host, via status/boxes/<label>.json — empty when no box has
+ // reported it yet. Best-effort, bound by the ~5-minute status flush
+ // cadence; empty on a create response, since no box has adopted the
+ // record before this call returns it.
+ StagedVersion string `json:"staged_version"`
}
// instanceCreate asks the console to create one instance record — the
diff --git a/instances_cli.go b/instances_cli.go
index 0ee781a66..b7541092d 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -268,9 +268,9 @@ func runInstancesList(args []string) {
log.Fatalf("instances list: %v", err)
}
w := newTable(stdout)
- fmt.Fprintln(w, "HOST\tREGION\tCHANNEL\tPOOL\tCREATED\tBY")
+ fmt.Fprintln(w, "HOST\tREGION\tCHANNEL\tPOOL\tVERSION\tCREATED\tBY")
for _, r := range recs {
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Region, r.Channel, r.Pool, r.CreatedAt, r.CreatedBy)
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Region, r.Channel, r.Pool, r.StagedVersion, r.CreatedAt, r.CreatedBy)
}
w.Flush()
if truncated {
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index c18cd88ad..d3a5b03dc 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -31,6 +31,7 @@ import (
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
)
// instanceStoreTimeout bounds every store round trip these handlers make,
@@ -152,8 +153,63 @@ func (s *Server) instanceManifest(ctx context.Context, w http.ResponseWriter, ac
}
}
+// instanceListRecord wraps provision.Record with the console's own
+// staged_version join (Task 8, issue #78) rather than adding the field to
+// Record itself: Record is the wire shape CreateRecord persists to the
+// bucket, and staged_version is never written there — it is computed fresh
+// at every list call from status/boxes/*.json, so giving Record the field
+// would risk a future write path serializing a stale or zero value into a
+// stored record that this join was never meant to touch.
+type instanceListRecord struct {
+ provision.Record
+ // StagedVersion is registry.Route.Version as last reported by whichever
+ // box's adopt pass claimed this host, read from that box's own
+ // status/boxes/<label>.json (internal/status's bucket-private Metrics
+ // block — see its package doc). Empty when no box has reported this
+ // host yet: a brand new record, or one whose box hasn't ticked its next
+ // ~5-minute status flush since create. Best-effort and freshness-bound
+ // by that flush cadence — the deploy watch does NOT read this field.
+ StagedVersion string `json:"staged_version"`
+}
+
+// stagedVersions builds a host -> version map from every box's own private
+// RouteVersions (status/boxes/*.json), through status.LoadBoxes — the same
+// loader and the same skip-corrupt-object rule the operator fleet page's
+// HostRows reads through (internal/console/view.go), so this join and that
+// page can never disagree about what a box last reported.
+//
+// A store that cannot be listed at all — not one corrupt object, the whole
+// read failing — degrades to "no staged versions known" rather than
+// answering a 500: this join is best-effort observability on top of the
+// list, never load-bearing for it. A nil store (a console with no
+// deployment bucket configured) is not reachable via handleAPIInstanceList
+// today — instanceGate already refuses those with a 503 before this ever
+// runs — but the nil check is kept here too so this helper degrades safely
+// for any future caller that reaches it before that gate.
+func stagedVersions(ctx context.Context, s release.Store) map[string]string {
+ if s == nil {
+ return nil
+ }
+ boxes, err := status.LoadBoxes(ctx, s)
+ if err != nil {
+ log.Printf("console: api instances: could not load status boxes for staged_version: %v", err)
+ return nil
+ }
+ versions := map[string]string{}
+ for _, b := range boxes {
+ if b.Metrics == nil {
+ continue
+ }
+ for host, v := range b.Metrics.RouteVersions {
+ versions[host] = v
+ }
+ }
+ return versions
+}
+
// handleAPIInstanceList answers `carlos instances list`: this app's records,
-// capped and honest about the cap.
+// capped and honest about the cap, each carrying its best-effort
+// staged_version (Task 8, issue #78).
func (s *Server) handleAPIInstanceList(w http.ResponseWriter, r *http.Request) {
_, acct, app, ok := s.instanceGate(w, r)
if !ok {
@@ -167,10 +223,12 @@ func (s *Server) handleAPIInstanceList(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadGateway, "could not list instances")
return
}
- if recs == nil {
- recs = []provision.Record{}
+ versions := stagedVersions(ctx, s.cfg.Releases)
+ out := make([]instanceListRecord, len(recs))
+ for i, rec := range recs {
+ out[i] = instanceListRecord{Record: rec, StagedVersion: versions[rec.Host]}
}
- writeJSON(w, http.StatusOK, map[string]any{"instances": recs, "truncated": truncated})
+ writeJSON(w, http.StatusOK, map[string]any{"instances": out, "truncated": truncated})
}
// handleAPIInstanceGet answers one record. A host with no record is the
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go
index a7905133d..9ef699780 100644
--- a/internal/console/apiinstances_test.go
+++ b/internal/console/apiinstances_test.go
@@ -24,6 +24,7 @@ import (
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
)
// rawReq is the tests' one-shot request: any method, an optional bearer and
@@ -431,6 +432,75 @@ func TestAPIInstanceListAndGet(t *testing.T) {
assertNotFound(t, "get of a traversal host", want, status, h, out)
}
+// TestAPIInstanceListStagedVersion (issue #78): the list join. A record
+// whose host was named in a box's private status/boxes/<label>.json
+// RouteVersions carries that box's reported version as staged_version; a
+// record no box has reported yet gets the empty string; and neither a
+// garbage object under status/boxes/ nor an entirely absent one turns the
+// list into a 500 — the join is best-effort observability layered onto the
+// record at read time, never load-bearing for the list itself.
+func TestAPIInstanceListStagedVersion(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-staged")
+ enableInstances(t, fx, "titogo", titoManifest())
+ u := instancesURL(fx, "bab", "titogo")
+ for _, h := range []string{"acme.go.tito.io", "beta.go.tito.io"} {
+ if code, _, out := rawReq(t, http.MethodPost, u, token,
+ fmt.Sprintf(`{"host":%q,"region":"ie"}`, h)); code != http.StatusCreated {
+ t.Fatalf("create %s = %d %s", h, code, out)
+ }
+ }
+
+ // No box has reported at all yet: the list must still serve, every
+ // record's staged_version empty.
+ code, _, out := rawReq(t, http.MethodGet, u, token, "")
+ if code != http.StatusOK {
+ t.Fatalf("list with no status objects = %d %s, want 200", code, out)
+ }
+ var list struct {
+ Instances []struct {
+ Host string `json:"host"`
+ StagedVersion string `json:"staged_version"`
+ } `json:"instances"`
+ }
+ if err := json.Unmarshal([]byte(out), &list); err != nil {
+ t.Fatalf("list body %s: %v", out, err)
+ }
+ for _, r := range list.Instances {
+ if r.StagedVersion != "" {
+ t.Errorf("%s staged_version = %q before any box reported, want empty", r.Host, r.StagedVersion)
+ }
+ }
+
+ // Seed a box snapshot naming acme's staged version — beta gets none —
+ // and a garbage object alongside it that must be skipped, not fatal.
+ box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
+ box.Metrics = &status.BoxMetrics{RouteVersions: map[string]string{"acme.go.tito.io": "abc1234"}}
+ if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.rel.Put(context.Background(), "status/boxes/broken.json", []byte("{not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+
+ code, _, out = rawReq(t, http.MethodGet, u, token, "")
+ if code != http.StatusOK {
+ t.Fatalf("list with a seeded box and a garbage object = %d %s, want 200", code, out)
+ }
+ if err := json.Unmarshal([]byte(out), &list); err != nil {
+ t.Fatalf("list body %s: %v", out, err)
+ }
+ got := map[string]string{}
+ for _, r := range list.Instances {
+ got[r.Host] = r.StagedVersion
+ }
+ if got["acme.go.tito.io"] != "abc1234" {
+ t.Errorf("acme staged_version = %q, want abc1234", got["acme.go.tito.io"])
+ }
+ if got["beta.go.tito.io"] != "" {
+ t.Errorf("beta staged_version = %q, want empty (no box has reported it)", got["beta.go.tito.io"])
+ }
+}
+
// TestAPIInstanceDelete: deletion is idempotent (the caller's goal is "not
// there"), survives the manifest being retired, and still refuses a host
// that isn't one.
diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go
index 0ad706fa6..3c485445b 100644
--- a/internal/status/contract_test.go
+++ b/internal/status/contract_test.go
@@ -50,10 +50,17 @@ var allowedBoxKeys = map[string][]string{
// allowedFleetKeys below has no "metrics" entry: BuildFleet's explicit
// field-by-field copy into FleetBox never names it, so TestFleetSchemaAllowlisted
// would fail the moment that ever changed.
- "": {"generated_at", "label", "region", "routes", "load", "capacity", "replication", "adoption_running", "config_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"},
+ //
+ // "metrics.route_versions" (Task 8, issue #78) carries real hostnames —
+ // the one field under "metrics" that isn't just an unbanded number —
+ // safe for exactly the same bucket-private reason: BuildFleet's
+ // field-by-field copy never names "metrics" at all, 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", "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"},
+ "metrics.route_versions": {wildcardAnyKey},
}
// allowedFleetKeys are THE schema for Fleet's JSON output. Same friction as
@@ -83,6 +90,16 @@ func assertOnlyKeys(t *testing.T, j []byte, allowedKeys map[string][]string) {
walkKeys(t, decoded, "", allowedKeys)
}
+// wildcardAnyKey marks a path (in an allowedKeys map) whose object keys are
+// DATA, not a fixed field-name schema — metrics.route_versions (Task 8,
+// issue #78) is host -> Version, so its keys are hostnames chosen at
+// runtime, which no fixed allowlist could name in advance. Values still
+// recurse normally (a route_versions entry that ever held a nested object
+// instead of a plain string would still trip the "unexpected key" check one
+// level down), so this only relaxes the KEY-NAME check, never the shape
+// check.
+const wildcardAnyKey = "*"
+
// walkKeys recursively traverses the decoded JSON structure, checking that
// every key at every path is in the allowlist. It handles maps and arrays
// appropriately, using "" for root, "fieldname" for nested maps, and
@@ -91,19 +108,23 @@ func walkKeys(t *testing.T, obj interface{}, path string, allowedKeys map[string
switch v := obj.(type) {
case map[string]interface{}:
// This is a map (object). Check that every key is in the allowlist
- // for this path.
+ // for this path — unless the path is wildcarded (see wildcardAnyKey),
+ // in which case the keys themselves are data.
allowed := allowedKeys[path]
+ wildcard := len(allowed) == 1 && allowed[0] == wildcardAnyKey
for key := range v {
- found := false
- for _, a := range allowed {
- if a == key {
- found = true
- break
+ if !wildcard {
+ found := false
+ for _, a := range allowed {
+ if a == key {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("unexpected key %q at path %q", key, path)
+ continue
}
- }
- if !found {
- t.Errorf("unexpected key %q at path %q", key, path)
- continue
}
// Recurse into this key's value, building the child path.
var childPath string
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go
index 92b69ad1c..5fb6f128e 100644
--- a/internal/status/snapshot.go
+++ b/internal/status/snapshot.go
@@ -100,6 +100,17 @@ type BoxMetrics struct {
DiskFreeBytes *int64 `json:"disk_free_bytes,omitempty"`
Release string `json:"release,omitempty"` // running binary's version.Build
Tunnel string `json:"tunnel,omitempty"` // ""|"connected"|"reconnecting"
+ // RouteVersions is host -> registry.Route.Version for every non-platform
+ // route this box carries with a Version the adopt pass has stamped
+ // (issue #78's staged_version chip) — a hostname-keyed map, which is
+ // exactly why it lives here and nowhere else: BuildFleet copies Box's
+ // fields into FleetBox one at a time by name and never names Metrics, so
+ // this map can carry real hostnames without the public fleet.json ever
+ // seeing one. It is the box's OWN routes only (CollectBox is called
+ // per-box), never a fleet-wide join. Omitted entirely when this box has
+ // no versioned route, same "absent, not empty" rule as the rest of this
+ // block.
+ RouteVersions map[string]string `json:"route_versions,omitempty"`
}
// RouteCounts is aggregate-only: no host, no socket path, just how many.
@@ -265,6 +276,13 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box {
var counts RouteCounts
var awakeKnown = true
awake, asleep := 0, 0
+ // routeVersions is issue #78's staged_version chip: host -> Version for
+ // this box's own non-platform routes that have one stamped. Built
+ // alongside the counts above rather than in a second pass over routes,
+ // and nil (not an empty map) until the first entry, so an all-unversioned
+ // box's Metrics attachment check below sees exactly "nothing to report"
+ // rather than a present-but-empty map.
+ var routeVersions map[string]string
for _, r := range routes {
// Platform-minted routes (internal/domains) are additional NAMES for
@@ -272,10 +290,18 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box {
// both would roughly double every fleet-wide number the moment a
// deployment sets PLATFORM_APPS_DOMAIN, publishing a fleet larger
// than the one that exists. Same reasoning as the opt-in and
- // null-propagation rules: the aggregate never overstates.
+ // null-propagation rules: the aggregate never overstates. It is also
+ // why routeVersions skips them here: a platform alias's Version is
+ // the same route's, under a second name, not a second instance's.
if r.Platform {
continue
}
+ if r.Version != "" {
+ if routeVersions == nil {
+ routeVersions = map[string]string{}
+ }
+ routeVersions[r.Host] = r.Version
+ }
switch {
case r.Kind == "static":
counts.Static++
@@ -340,16 +366,18 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box {
}
// Metrics attaches only when something is known — an all-unknown Probes
- // publishes no block at all, the same "unknown stays absent, never a
- // guess" rule every other field on Box follows, and it keeps a box that
- // can't answer any of the six fields indistinguishable on the wire from
- // a box running code that predates Metrics entirely.
+ // AND no versioned route publishes no block at all, the same "unknown
+ // stays absent, never a guess" rule every other field on Box follows,
+ // and it keeps a box that can't answer any of the seven fields
+ // indistinguishable on the wire from a box running code that predates
+ // Metrics entirely.
if p.MemTotalBytes != nil || p.MemAvailBytes != nil || p.Load1 != nil ||
- p.DiskFreeBytes != nil || p.Release != "" || p.Tunnel != "" {
+ p.DiskFreeBytes != nil || p.Release != "" || p.Tunnel != "" || len(routeVersions) > 0 {
box.Metrics = &BoxMetrics{
MemTotalBytes: p.MemTotalBytes, MemAvailBytes: p.MemAvailBytes,
Load1: p.Load1, DiskFreeBytes: p.DiskFreeBytes,
Release: p.Release, Tunnel: p.Tunnel,
+ RouteVersions: routeVersions,
}
}
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index 8c73ed3eb..928b9d609 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -273,6 +273,72 @@ func TestBoxBackCompatParse(t *testing.T) {
}
}
+// ---- Task 8: staged_version (route versions, private) ----------------------
+
+// TestCollectBoxCarriesRouteVersions proves CollectBox attaches a host ->
+// Version map onto the raw Metrics block for every non-platform route that
+// carries a Version (registry.Route.Version, stamped by the adopt pass), and
+// that WriteBox lands it in status/boxes/<label>.json — while BuildFleet's
+// public fleet.json, which never copies Metrics by construction (see
+// snapshot.go's package doc), carries no trace of the hostname. A platform-
+// minted alias route and a route with no Version yet must not appear in the
+// map at all.
+func TestCollectBoxCarriesRouteVersions(t *testing.T) {
+ routes := []registry.Route{
+ {Host: "h.test", Kind: "instance", Status: "ready", Version: "abc1234"},
+ {Host: "unversioned.test", Kind: "instance", Status: "ready"},
+ {Host: "platform.test", Kind: "instance", Status: "ready", Version: "zzz9999", Platform: true},
+ }
+ b := status.CollectBox(routes, status.Probes{MachineID: "m"}, time.Now())
+ if b.Metrics == nil {
+ t.Fatal("metrics block missing — RouteVersions should have attached it")
+ }
+ if len(b.Metrics.RouteVersions) != 1 || b.Metrics.RouteVersions["h.test"] != "abc1234" {
+ t.Fatalf("route_versions = %+v, want exactly {h.test: abc1234}", b.Metrics.RouteVersions)
+ }
+
+ s := teststore(t)
+ ctx := context.Background()
+ if err := status.WriteBox(ctx, s, b); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := s.Get(ctx, "status/boxes/"+b.Label+".json")
+ if err != nil {
+ t.Fatalf("Get written box: %v", err)
+ }
+ var round status.Box
+ if err := json.Unmarshal(raw, &round); err != nil {
+ t.Fatalf("unmarshal written box: %v", err)
+ }
+ if round.Metrics == nil || round.Metrics.RouteVersions["h.test"] != "abc1234" {
+ t.Errorf("written box route_versions = %+v, want {h.test: abc1234}: %s", round.Metrics, raw)
+ }
+
+ f, err := status.BuildFleet(ctx, s, routes, time.Now(), 5*time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fj, err := json.Marshal(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(fj), "h.test") {
+ t.Errorf("fleet.json leaked a route_versions hostname: %s", fj)
+ }
+}
+
+// TestCollectBoxRouteVersionsEmptyOmitsField proves a box with no versioned
+// route contributes no route_versions key at all — omitted, not an empty
+// object — matching the same "unknown/absent stays absent" convention every
+// other Metrics field follows.
+func TestCollectBoxRouteVersionsEmptyOmitsField(t *testing.T) {
+ routes := []registry.Route{{Host: "h.test", Kind: "instance", Status: "ready"}}
+ b := status.CollectBox(routes, status.Probes{MachineID: "m"}, time.Now())
+ if b.Metrics != nil {
+ t.Fatalf("metrics block should stay nil with nothing else known, got %+v", b.Metrics)
+ }
+}
+
// ---- Task 3: fleet merge + publisher --------------------------------------
// teststore is a fresh DirStore over a scratch directory — every fleet test