status+console: staged_version surfaces through the instances API (#78)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+276
−26
apimode.go+7 −0instances_cli.go+2 −2internal/console/apiinstances.go+62 −4internal/console/apiinstances_test.go+70 −0internal/status/contract_test.go+35 −14internal/status/snapshot.go+34 −6internal/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 |