feat: DNS and cert health stamps for route aliases (#151)
Extends the reconcile pass's DNS-health (section 4) and TLS-health (section 5) loops to stamp route_aliases rows the same way route rows are stamped: reg.ListAliases() is read once, guarded by the same cfg.Resolve != nil || cfg.AcmeDir != "" condition each section already gates on, so a box with neither seam configured pays no extra query. DNS stamping reuses the route loop's verdict logic (extracted into a shared dnsStatus helper so the two can't drift apart): resolver failure stamps "" (unknown, never a guess), an edge-IP match stamps "pointing", anything else "elsewhere" — landed via SetAliasDNSStatus(a.Alias, status, now). Cert stamping calls the existing hostCertExpiry(cfg.AcmeDir, a.Alias, cfg.AppsDomain) and lands it via SetAliasCertExpiresAt. Both loops log-and-continue per item, matching the route stamping style. Neither stamp touches Fingerprint — Task 2 already excludes these columns from it, so a stamping pass alone still never forces an edge reload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+120
−10
internal/domains/reconcile.go+43 −10internal/domains/reconcile_test.go+77 −0
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index f879b461e..c5d4f72f5 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -406,6 +406,19 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| } |
| routes = kept |
| + // aliases are the extra hostnames route_aliases holds (Task 2/#151), |
| + // health-stamped by sections 4 and 5 below exactly like the routes they |
| + // point at. Listed ONCE, above both sections, and guarded the same way |
| + // each section already guards itself — a box with neither Resolve nor |
| + // AcmeDir configured pays no aliases query at all. |
| + var aliases []registry.RouteAlias |
| + if cfg.Resolve != nil || cfg.AcmeDir != "" { |
| + aliases, err = reg.ListAliases() |
| + if err != nil { |
| + return fmt.Errorf("domains: list aliases: %w", err) |
| + } |
| + } |
| + |
| // --- 4. DNS health -------------------------------------------------------- |
| if cfg.Resolve != nil { |
| edge := map[string]bool{} |
| @@ -414,20 +427,17 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| } |
| now := cfg.Now().Unix() |
| for _, rt := range routes { |
| - status := "" // resolver failure is UNKNOWN, never a guess |
| - if ips, err := cfg.Resolve(rt.Host); err == nil { |
| - status = "elsewhere" |
| - for _, ip := range ips { |
| - if edge[ip] { |
| - status = "pointing" |
| - break |
| - } |
| - } |
| - } |
| + status := dnsStatus(cfg.Resolve, rt.Host, edge) |
| if err := reg.SetDNSStatus(rt.Host, status, now); err != nil { |
| log.Printf("domains: stamp dns on %s: %v", rt.Host, err) |
| } |
| } |
| + for _, a := range aliases { |
| + status := dnsStatus(cfg.Resolve, a.Alias, edge) |
| + if err := reg.SetAliasDNSStatus(a.Alias, status, now); err != nil { |
| + log.Printf("domains: stamp dns on alias %s: %v", a.Alias, err) |
| + } |
| + } |
| } |
| // --- 5. TLS health -------------------------------------------------------- |
| @@ -438,10 +448,33 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| log.Printf("domains: stamp cert expiry on %s: %v", rt.Host, err) |
| } |
| } |
| + for _, a := range aliases { |
| + at := hostCertExpiry(cfg.AcmeDir, a.Alias, cfg.AppsDomain) |
| + if err := reg.SetAliasCertExpiresAt(a.Alias, at); err != nil { |
| + log.Printf("domains: stamp cert expiry on alias %s: %v", a.Alias, err) |
| + } |
| + } |
| } |
| return nil |
| } |
| +// dnsStatus resolves host through resolve and classifies the answer against |
| +// edge: a resolver failure is UNKNOWN ("") — never a guess — an edge-IP match |
| +// is "pointing", anything else is "elsewhere". Shared by the route and alias |
| +// loops in section 4 so the two verdicts can never drift apart. |
| +func dnsStatus(resolve func(host string) ([]string, error), host string, edge map[string]bool) string { |
| + ips, err := resolve(host) |
| + if err != nil { |
| + return "" |
| + } |
| + for _, ip := range ips { |
| + if edge[ip] { |
| + return "pointing" |
| + } |
| + } |
| + return "elsewhere" |
| +} |
| + |
| // OpsSqid is the built-in ops account's frozen sqid (registry id 1). Every |
| // deployment has it, console or not. |
| var OpsSqid = Sqid(1) |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| index 4c25b21b0..ab3b8b652 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -624,6 +624,83 @@ func TestReconcileStampsCertExpiry(t *testing.T) { |
| } |
| } |
| +// aliasByName finds an alias row by hostname among reg.ListAliases(), the |
| +// same read-back-from-the-registry shape mustGet uses for routes. |
| +func aliasByName(t *testing.T, reg *registry.Registry, alias string) registry.RouteAlias { |
| + t.Helper() |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, a := range aliases { |
| + if a.Alias == alias { |
| + return a |
| + } |
| + } |
| + t.Fatalf("alias %s missing", alias) |
| + return registry.RouteAlias{} |
| +} |
| + |
| +func TestReconcileStampsAliasDNS(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "app.test", Kind: "static", Status: "ready"}, |
| + ) |
| + for _, a := range []string{"points-alias.test", "elsewhere-alias.test", "broken-alias.test"} { |
| + if err := reg.UpsertAlias(registry.RouteAlias{Alias: a, Host: "app.test"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + c := cfg(t) |
| + c.Resolve = func(host string) ([]string, error) { |
| + switch host { |
| + case "points-alias.test": |
| + return []string{"198.51.100.9", "203.0.113.7"}, nil // edge IP among others |
| + case "elsewhere-alias.test": |
| + return []string{"198.51.100.9"}, nil |
| + default: |
| + return nil, errors.New("NXDOMAIN or worse") |
| + } |
| + } |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + now := c.Now().Unix() |
| + for alias, want := range map[string]string{ |
| + "points-alias.test": "pointing", |
| + "elsewhere-alias.test": "elsewhere", |
| + "broken-alias.test": "", // resolver failure is UNKNOWN, never a guess |
| + } { |
| + got := aliasByName(t, reg, alias) |
| + if got.DNSStatus != want { |
| + t.Errorf("%s dns_status = %q, want %q", alias, got.DNSStatus, want) |
| + } |
| + if got.DNSCheckedAt != now { |
| + t.Errorf("%s dns_checked_at = %d, want %d", alias, got.DNSCheckedAt, now) |
| + } |
| + } |
| +} |
| + |
| +func TestReconcileStampsAliasCert(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "kass.training", Kind: "instance", App: "kass", |
| + Status: "ready", Addr: "127.0.0.1:9000"}, |
| + ) |
| + if err := reg.UpsertAlias(registry.RouteAlias{Alias: "kass-alias.training", Host: "kass.training"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + acmeDir := t.TempDir() |
| + notAfter := time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC) |
| + writeCertBundle(t, acmeDir, "kass-alias.training", notAfter) |
| + c := cfg(t) |
| + c.AcmeDir = acmeDir |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := aliasByName(t, reg, "kass-alias.training"); got.CertExpiresAt != notAfter.Unix() { |
| + t.Errorf("kass-alias.training cert_expires_at = %d, want %d", got.CertExpiresAt, notAfter.Unix()) |
| + } |
| +} |
| + |
| func TestReconcileInertWithoutAppsDomain(t *testing.T) { |
| s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |