carlos / platform Public

status: custom-domain routes get a reading, and readings carry their age

Paul Campbell pushed by paul@keymail.dev 98f7fa86d4be8c8d265faada1f437a8422aed05c parent ad78721
4 files changed, +136 −10
  • internal/console/apidomains.go +8 −0
  • internal/console/apidomains_test.go +42 −0
  • internal/status/snapshot.go +37 −10
  • internal/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")
+ }
+}