zdd-6 task 13: carlos sidecars ls shows the serving build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+68
−29
internal/console/apiinstances.go+5 −1internal/console/apisidecars.go+9 −11internal/console/apisidecars_test.go+9 −9internal/status/snapshot.go+18 −1internal/status/snapshot_test.go+21 −0sidecars_cli.go+6 −7
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 36f82cf3a..6a1a123d3 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -336,6 +336,7 @@ func (s *Server) healthByHost(ctx context.Context, routeFor map[string]registry. |
| // disagreement no reader could diagnose. |
| type boxJoin struct { |
| versions map[string]string |
| + serving map[string]string |
| upstreams map[string][]status.UpstreamState |
| dbBytes map[string]int64 |
| rollouts map[string]rolloutView |
| @@ -429,7 +430,7 @@ func loadBoxJoin(ctx context.Context, s release.Store, now time.Time) boxJoin { |
| // joinBoxes is loadBoxJoin's fold over snapshots already read, so a page that |
| // has its boxes reads them once. |
| func joinBoxes(boxes []status.Box, now time.Time) boxJoin { |
| - j := boxJoin{versions: map[string]string{}, upstreams: map[string][]status.UpstreamState{}, |
| + j := boxJoin{versions: map[string]string{}, serving: map[string]string{}, upstreams: map[string][]status.UpstreamState{}, |
| dbBytes: map[string]int64{}, rollouts: map[string]rolloutView{}} |
| for _, b := range boxes { |
| if b.Metrics == nil || status.BoxStale(b, now, status.SnapshotPeriod) { |
| @@ -438,6 +439,9 @@ func joinBoxes(boxes []status.Box, now time.Time) boxJoin { |
| for host, v := range b.Metrics.RouteVersions { |
| j.versions[host] = v |
| } |
| + for host, v := range b.Metrics.RouteServing { |
| + j.serving[host] = v |
| + } |
| for host, ups := range b.Metrics.Upstreams { |
| j.upstreams[host] = ups |
| } |
diff --git a/internal/console/apisidecars.go b/internal/console/apisidecars.go| index e6a08b12a..67f9a491a 100644 |
| --- a/internal/console/apisidecars.go |
| +++ b/internal/console/apisidecars.go |
| @@ -119,16 +119,14 @@ func (s *Server) sidecarManifest(ctx context.Context, w http.ResponseWriter, acc |
| // meant to touch. |
| type sidecarListRecord struct { |
| provision.SidecarRecord |
| - // RunningVersion is what the sidecar's WORKER last reported, read from |
| - // whichever box carries its route via that box's own |
| - // status/boxes/<label>.json (loadBoxJoin, which drops a box that has gone |
| - // stale rather than letting it answer as current). For a sidecar this is |
| - // specifically the version stamped by the worker's own /v1/ready call |
| - // (activator's SetVersions) — the adopt pass never stamps a sidecar row, |
| - // because release.Adoptable excludes them — so a sidecar that has never |
| - // reported reads EMPTY here and must be rendered "unknown", never as the |
| - // channel's desired version. A stamp that overclaims is worse than an |
| - // absent one. |
| + // RunningVersion is the build the agent last started for the sidecar |
| + // (registry ServingVersion, zero-downtime plan 5), read from whichever |
| + // box carries its route via status/boxes/<label>.json's RouteServing, |
| + // under loadBoxJoin's staleness rule. NOT RouteVersions: since plan 4 |
| + // that is root's install stamp, which can name a build nothing runs yet, |
| + // and the worker's own /v1/ready report is display metadata only. A |
| + // sidecar that has not started anywhere reads EMPTY and is rendered |
| + // "unknown", never as the channel's desired version. |
| RunningVersion string `json:"running_version"` |
| } |
| @@ -151,7 +149,7 @@ func (s *Server) handleAPISidecarList(w http.ResponseWriter, r *http.Request) { |
| join := loadBoxJoin(ctx, s.cfg.Releases, time.Now()) |
| out := make([]sidecarListRecord, 0, len(recs)) |
| for _, rec := range recs { |
| - out = append(out, sidecarListRecord{SidecarRecord: rec, RunningVersion: join.versions[rec.Host]}) |
| + out = append(out, sidecarListRecord{SidecarRecord: rec, RunningVersion: join.serving[rec.Host]}) |
| } |
| if s.cfg.HostClaim { |
| pending, err := s.claimPendingViews(ctx, acct.Sqid, app, "sidecar") |
diff --git a/internal/console/apisidecars_test.go b/internal/console/apisidecars_test.go| index c1bfd088e..01a2111dd 100644 |
| --- a/internal/console/apisidecars_test.go |
| +++ b/internal/console/apisidecars_test.go |
| @@ -698,20 +698,20 @@ func TestAPISidecarListAndGet(t *testing.T) { |
| } |
| } |
| - // A fresh box reporting alpha's version, and a STALE one reporting |
| - // beta's, which must drop out of the join rather than answer as current. |
| + // A fresh box reporting alpha's serving version and gamma's installed |
| + // version, and a stale box reporting beta's serving version. |
| fresh := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) |
| - fresh.Metrics = &status.BoxMetrics{RouteVersions: map[string]string{ |
| - "alpha.titogo.bab.sidecar.internal": "abc1234"}} |
| + fresh.Metrics = &status.BoxMetrics{ |
| + RouteServing: map[string]string{"alpha.titogo.bab.sidecar.internal": "abc1234"}, |
| + // gamma is installed but not started: root's stamp is not a running build. |
| + RouteVersions: map[string]string{"gamma.titogo.bab.sidecar.internal": "f00d123"}, |
| + } |
| if err := status.WriteBox(context.Background(), fx.rel, fresh); err != nil { |
| t.Fatal(err) |
| } |
| stale := status.CollectBox(nil, status.Probes{MachineID: "box2"}, time.Now().Add(-time.Hour)) |
| - stale.Metrics = &status.BoxMetrics{RouteVersions: map[string]string{ |
| + stale.Metrics = &status.BoxMetrics{RouteServing: map[string]string{ |
| "beta.titogo.bab.sidecar.internal": "deadbeef"}} |
| - if err := status.WriteBox(context.Background(), fx.rel, stale); err != nil { |
| - t.Fatal(err) |
| - } |
| code, _, out := rawReq(t, http.MethodGet, u, token, "") |
| if code != http.StatusOK { |
| @@ -752,7 +752,7 @@ func TestAPISidecarListAndGet(t *testing.T) { |
| t.Errorf("beta running_version = %q, want empty — its only reporting box is stale", got["beta"]) |
| } |
| if got["gamma"] != "" { |
| - t.Errorf("gamma running_version = %q, want empty — no box has reported it", got["gamma"]) |
| + t.Errorf("gamma running_version = %q, want empty — an install stamp is not a running build", got["gamma"]) |
| } |
| // GET one. |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index 45788606c..b0ba61923 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -454,6 +454,12 @@ type BoxMetrics struct { |
| // no versioned route, same "absent, not empty" rule as the rest of this |
| // block. |
| RouteVersions map[string]string `json:"route_versions,omitempty"` |
| + // RouteServing is host -> the build the agent last started for that |
| + // route (registry ServingVersion, zero-downtime plan 5), for this box's |
| + // own non-platform routes that have one. RouteVersions is root's install |
| + // stamp and can name a build nothing runs yet; this is what runs. Private |
| + // and omitted when empty, for RouteVersions' reasons. |
| + RouteServing map[string]string `json:"route_serving,omitempty"` |
| // Upstreams is the box's per-upstream probe table, route host -> the |
| // UpstreamState of each addr behind it (multi-upstream routes only; a |
| // single-addr route with no health config is never probed and never |
| @@ -765,6 +771,10 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| // box's Metrics attachment check below sees exactly "nothing to report" |
| // rather than a present-but-empty map. |
| var routeVersions map[string]string |
| + // routeServing is plan 5's ServingVersion: host -> the build the agent |
| + // last started, for this box's own non-platform routes that have one. |
| + // Same nil-until-first-entry convention as routeVersions. |
| + var routeServing map[string]string |
| // aliasReadings is issue #151's Metrics.Aliases source. Declared here, |
| // above the route loop, so the route loop below (a first-route claim's |
| // ROUTE readings) and the p.Aliases loop after it (genuine alias rows) |
| @@ -793,6 +803,12 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| } |
| routeVersions[r.Host] = r.Version |
| } |
| + if r.ServingVersion != "" { |
| + if routeServing == nil { |
| + routeServing = map[string]string{} |
| + } |
| + routeServing[r.Host] = r.ServingVersion |
| + } |
| if r.Kind != "static" && r.DBPath != "" && p.DBBytes != nil { |
| if n := p.DBBytes(r.DBPath); n != nil { |
| if routeDBBytes == nil { |
| @@ -914,7 +930,7 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| if p.MemTotalBytes != nil || p.MemAvailBytes != nil || p.Load1 != nil || |
| p.DiskFreeBytes != nil || p.Release != "" || p.Tunnel != "" || |
| p.InstanceID != "" || p.Platform != "" || p.SystemChannel != "" || p.System != nil || |
| - len(routeVersions) > 0 || len(aliasReadings) > 0 || len(p.Upstreams) > 0 || |
| + len(routeVersions) > 0 || len(routeServing) > 0 || len(aliasReadings) > 0 || len(p.Upstreams) > 0 || |
| len(routeDBBytes) > 0 || p.Layout != nil || len(p.ConfigRefusals) > 0 || len(p.ReleaseRefusals) > 0 || len(p.ProofWaits) > 0 || p.Rollout != nil || p.ConfigGuard != "" || |
| p.Alerts != nil || p.GeoIP != nil { |
| box.Metrics = &BoxMetrics{ |
| @@ -937,6 +953,7 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| InstanceID: p.InstanceID, |
| Alerts: p.Alerts, |
| RouteVersions: routeVersions, |
| + RouteServing: routeServing, |
| Aliases: aliasReadings, |
| // Carried through as the prober handed it over: proberSet.State |
| // already returns a fresh copy per call, so there is nothing here |
diff --git a/internal/status/snapshot_test.go b/internal/status/snapshot_test.go| index 1afd4c3b7..286e2dfea 100644 |
| --- a/internal/status/snapshot_test.go |
| +++ b/internal/status/snapshot_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -168,3 +169,23 @@ func TestTheRolloutReportRidesThePrivateMetrics(t *testing.T) { |
| t.Fatal("the flag off publishes no rollout block and no Metrics because of it") |
| } |
| } |
| + |
| +// RouteServing is the build the agent started for each route (plan 5's |
| +// ServingVersion), carried beside RouteVersions, root's install stamp. |
| +func TestCollectBoxCarriesTheServingBuild(t *testing.T) { |
| + routes := []registry.Route{ |
| + {Host: "side.titogo.bab.sidecar.internal", Kind: "sidecar", Version: "v2", ServingVersion: "v1"}, |
| + {Host: "never.titogo.bab.sidecar.internal", Kind: "sidecar", Version: "v2"}, |
| + {Host: "alias.example.com", Platform: true, ServingVersion: "v9"}, |
| + } |
| + b := CollectBox(routes, Probes{MachineID: "m"}, time.Now()) |
| + if b.Metrics == nil || b.Metrics.RouteServing["side.titogo.bab.sidecar.internal"] != "v1" { |
| + t.Fatalf("RouteServing = %+v", b.Metrics) |
| + } |
| + if _, ok := b.Metrics.RouteServing["never.titogo.bab.sidecar.internal"]; ok { |
| + t.Error("a route that never started has a serving build") |
| + } |
| + if _, ok := b.Metrics.RouteServing["alias.example.com"]; ok { |
| + t.Error("a platform alias carries a serving build") |
| + } |
| +} |
diff --git a/sidecars_cli.go b/sidecars_cli.go| index ec7dc5e0c..652528eb3 100644 |
| --- a/sidecars_cli.go |
| +++ b/sidecars_cli.go |
| @@ -366,13 +366,12 @@ func runSidecarsCreate(args []string) { |
| mutedf(stdout, " an always-on one (hibernate: false in the app's sidecar settings) starts as soon as the row lands") |
| } |
| -// sidecarVersionCell renders `ls`'s VERSION column: the raw running version |
| -// when the sidecar has reported one via /v1/ready, or the literal |
| -// "unknown" when it never has — the honesty rule this branch was built |
| -// around (design §9.2, binding on this task): a stamp that overclaims is |
| -// worse than an absent one, so this must NEVER render the record's own |
| -// Channel (the desired version) as a stand-in for a version nobody has |
| -// confirmed is actually running. |
| +// sidecarVersionCell renders `ls`'s VERSION column: the build the agent |
| +// last started for the sidecar, or the literal "unknown" when it never has |
| +// — the honesty rule this branch was built around (design §9.2, binding on |
| +// this task): a stamp that overclaims is worse than an absent one, so this |
| +// must NEVER render the record's own Channel (the desired version) as a |
| +// stand-in for a version nobody has confirmed is actually running. |
| func sidecarVersionCell(running string) string { |
| if running == "" { |
| return "unknown" |