status: custom-domain routes get a reading, and readings carry their age
4 files changed,
+136
−10
internal/console/apidomains.go+8 −0internal/console/apidomains_test.go+42 −0internal/status/snapshot.go+37 −10internal/status/status_test.go+49 −0
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index ef6bfa575..8da9ed21e 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -688,6 +688,14 @@ func aliasReadings(ctx context.Context, s release.Store, now time.Time) map[stri |
| continue |
| } |
| for host, reading := range b.Metrics.Aliases { |
| + // Freshest stamp wins. An alias whose primary is everywhere-class |
| + // materializes on several boxes, and map iteration order is not a |
| + // tie-break — without this a box that has stopped stamping can |
| + // deterministically overwrite a live box's reading with a frozen |
| + // one. Ties keep the incumbent, so the fold is stable. |
| + if prior, ok := readings[host]; ok && prior.DNSCheckedAt >= reading.DNSCheckedAt { |
| + continue |
| + } |
| readings[host] = reading |
| } |
| } |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| index dc4562297..4557e600c 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -1455,3 +1455,45 @@ func TestAPIDomainAttachStillConvergesTheCallersOwnDeploymentRoute(t *testing.T) |
| t.Fatalf("re-attach of the caller's own hostname = %+v, want 200 created=false", got) |
| } |
| } |
| + |
| +// readingsTestStore is a bare release store for aliasReadings tests that |
| +// don't need a full consoleFixture — same DirStore-over-TempDir pattern |
| +// status_test.go's teststore uses. |
| +func readingsTestStore(t *testing.T) release.Store { |
| + t.Helper() |
| + return release.NewDirStore(t.TempDir()) |
| +} |
| + |
| +// writeBoxWithAlias publishes one box whose Metrics.Aliases carries a single |
| +// reading, all under the fixed hostname "shop.customer.com" — the tests |
| +// using this helper are about the FOLD across boxes (freshest stamp wins), |
| +// not about hostname routing, so every box in a given test contends for the |
| +// same key on purpose. |
| +func writeBoxWithAlias(t *testing.T, s release.Store, label string, now time.Time, reading status.AliasReading) { |
| + t.Helper() |
| + if err := status.WriteBox(context.Background(), s, status.Box{ |
| + Label: label, GeneratedAt: now, |
| + Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{ |
| + "shop.customer.com": reading, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// Two boxes can both carry an alias (an everywhere-class primary). The fold |
| +// must take the FRESHEST stamp, not whichever box the map happened to yield |
| +// last — otherwise a frozen reading deterministically overwrites a live one. |
| +func TestAliasReadingsPrefersTheFreshestStamp(t *testing.T) { |
| + s := readingsTestStore(t) |
| + now := time.Unix(1756300060, 0) |
| + writeBoxWithAlias(t, s, "annie", now, status.AliasReading{ |
| + Route: "app.example.com", DNSStatus: "elsewhere", DNSCheckedAt: 1756200000}) |
| + writeBoxWithAlias(t, s, "betty", now, status.AliasReading{ |
| + Route: "app.example.com", DNSStatus: "pointing", DNSCheckedAt: 1756300000}) |
| + |
| + got := aliasReadings(context.Background(), s, now) |
| + if got["shop.customer.com"].DNSStatus != "pointing" { |
| + t.Fatalf("reading = %+v, want the freshest box's stamp", got["shop.customer.com"]) |
| + } |
| +} |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index cf20091c7..a78cf5873 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -201,9 +201,16 @@ type BoxMetrics struct { |
| // hiding; DNSStatus/CertExpiresAt omit when unstamped ("" / 0), same |
| // unknown-stays-absent convention as every other Metrics field. |
| type AliasReading struct { |
| - Route string `json:"route"` |
| - DNSStatus string `json:"dns_status,omitempty"` |
| - CertExpiresAt int64 `json:"cert_expires_at,omitempty"` |
| + Route string `json:"route"` |
| + DNSStatus string `json:"dns_status,omitempty"` |
| + // DNSCheckedAt is when this box last RESOLVED the hostname (registry |
| + // dns_checked_at). Carried because a stamp without its age cannot be |
| + // judged: a box that has stopped stamping keeps publishing fresh |
| + // SNAPSHOTS full of frozen readings, and BoxStale — which measures the |
| + // snapshot, not the stamp — cannot see it. Omitempty: unstamped stays |
| + // absent, never a zero that reads as 1970. |
| + DNSCheckedAt int64 `json:"dns_checked_at,omitempty"` |
| + CertExpiresAt int64 `json:"cert_expires_at,omitempty"` |
| } |
| // RouteCounts is aggregate-only: no host, no socket path, just how many. |
| @@ -384,6 +391,12 @@ 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 |
| + // 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) |
| + // both fold into the same map — nil (not an empty map) until the first |
| + // entry, same "nothing to report" convention as routeVersions. |
| + var aliasReadings map[string]AliasReading |
| for _, r := range routes { |
| // Platform-minted routes (internal/domains) are additional NAMES for |
| @@ -403,6 +416,22 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| } |
| routeVersions[r.Host] = r.Version |
| } |
| + // Non-platform routes carry hostname readings too. A first-route |
| + // claim (claims.go: "an empty route means this host is its own |
| + // primary") materializes a ROUTE, not an alias row, so without this |
| + // the domains surface can never take it past "pending" — which is |
| + // exactly what a static app deployed to its own custom domain has |
| + // always shown. Route is the host itself, which is what the claim's |
| + // empty Route field means. |
| + if r.DNSStatus != "" || r.CertExpiresAt != 0 { |
| + if aliasReadings == nil { |
| + aliasReadings = map[string]AliasReading{} |
| + } |
| + aliasReadings[r.Host] = AliasReading{ |
| + Route: r.Host, DNSStatus: r.DNSStatus, |
| + DNSCheckedAt: r.DNSCheckedAt, CertExpiresAt: r.CertExpiresAt, |
| + } |
| + } |
| switch { |
| case r.Kind == "static": |
| counts.Static++ |
| @@ -426,18 +455,16 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| counts.Resident++ |
| } |
| } |
| - // aliasReadings is issue #151's Metrics.Aliases source, built once here |
| - // (not a second pass) from p.Aliases — nil (not an empty map) until the |
| - // first entry, same "nothing to report" convention as routeVersions |
| - // above, and for the identical reason: an all-alias-less box's Metrics |
| - // attachment check below must see exactly "nothing to report". |
| - var aliasReadings map[string]AliasReading |
| + // p.Aliases (genuine route_aliases rows) runs AFTER the route loop above, |
| + // so a real alias wins its own key over the route-loop's fallback entry |
| + // for the same host. |
| for _, a := range p.Aliases { |
| if aliasReadings == nil { |
| aliasReadings = map[string]AliasReading{} |
| } |
| aliasReadings[a.Alias] = AliasReading{ |
| - Route: a.Host, DNSStatus: a.DNSStatus, CertExpiresAt: a.CertExpiresAt, |
| + Route: a.Host, DNSStatus: a.DNSStatus, |
| + DNSCheckedAt: a.DNSCheckedAt, CertExpiresAt: a.CertExpiresAt, |
| } |
| } |
diff --git a/internal/status/status_test.go b/internal/status/status_test.go| index 03ae43667..8f153f9dd 100644 |
| --- a/internal/status/status_test.go |
| +++ b/internal/status/status_test.go |
| @@ -1229,3 +1229,52 @@ func TestBoxStaleMatchesFleetBoxStale(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// A first-route claim materializes a ROUTE, not an alias. Without its stamps |
| +// on the wire the console can never take it past "pending" — which is what |
| +// `carlos deploy -kind static -host <site>` has always shown. |
| +func TestCollectBoxCarriesRouteDNSStampsForNonPlatformRoutes(t *testing.T) { |
| + routes := []registry.Route{{ |
| + Host: "story.customer.com", Status: "ready", App: "site", |
| + DNSStatus: "pointing", DNSCheckedAt: 1756300000, CertExpiresAt: 1764000000, |
| + }} |
| + box := status.CollectBox(routes, status.Probes{}, time.Unix(1756300060, 0)) |
| + |
| + got, ok := box.Metrics.Aliases["story.customer.com"] |
| + if !ok { |
| + t.Fatal("a claim-backed route must publish a reading") |
| + } |
| + if got.DNSStatus != "pointing" || got.CertExpiresAt != 1764000000 { |
| + t.Fatalf("reading = %+v", got) |
| + } |
| + if got.Route != "story.customer.com" { |
| + t.Fatalf("Route = %q, want the host as its own primary (an empty claim route means exactly that)", got.Route) |
| + } |
| + if got.DNSCheckedAt != 1756300000 { |
| + t.Fatalf("DNSCheckedAt = %d, want the stamp's own age carried", got.DNSCheckedAt) |
| + } |
| +} |
| + |
| +// Platform routes are the deployment's own hosts, not custom domains, and |
| +// they already have RouteVersions. Keep the map to what the domains surface |
| +// actually joins against. |
| +func TestCollectBoxOmitsPlatformRoutesFromReadings(t *testing.T) { |
| + routes := []registry.Route{ |
| + {Host: "app.bdk.oncarlos.com", Status: "ready", Platform: true, DNSStatus: "pointing"}, |
| + // A custom domain alongside it, so Metrics attaches at all: Box.Metrics |
| + // is a POINTER and stays nil when nothing is known (snapshot.go:485-510). |
| + // Without this row the assertion below would nil-panic rather than |
| + // testing anything. |
| + {Host: "shop.customer.com", Status: "ready", DNSStatus: "pointing", DNSCheckedAt: 1756300000}, |
| + } |
| + box := status.CollectBox(routes, status.Probes{}, time.Unix(1756300060, 0)) |
| + if box.Metrics == nil { |
| + t.Fatal("Metrics must attach once a custom domain has a reading") |
| + } |
| + if _, ok := box.Metrics.Aliases["app.bdk.oncarlos.com"]; ok { |
| + t.Fatal("a platform route must not appear in the hostname readings") |
| + } |
| + if _, ok := box.Metrics.Aliases["shop.customer.com"]; !ok { |
| + t.Fatal("the custom domain must appear") |
| + } |
| +} |