Merge pull request #304 from carlosframework/domains-health-per-box
domains: the per-box health stamps run on every box again (#303)
4 files changed,
+190
−10
e2e_test.go+7 −2internal/domains/reconcile.go+51 −0internal/domains/reconcile_test.go+109 −0internal/host/agent.go+23 −8
diff --git a/e2e_test.go b/e2e_test.go| index 9e63d7cb3..e0928d1b6 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -418,14 +418,19 @@ func TestEndToEndSmoke(t *testing.T) { |
| // echoapp was shipped under ops above, so ops is where it mints; the |
| // two-account case is TestMultiAccountEndToEnd. |
| acctSqid := release.OpsAccount |
| - if err := domains.Reconcile(ctx, store, reg, domains.Config{ |
| + dcfg := domains.Config{ |
| AppsDomain: "oncarlos.test", |
| EdgeIPs: []string{"203.0.113.7"}, |
| Resolve: func(host string) ([]string, error) { return []string{"203.0.113.7"}, nil }, |
| Now: time.Now, |
| - }); err != nil { |
| + } |
| + if err := domains.Reconcile(ctx, store, reg, dcfg); err != nil { |
| t.Fatalf("domains.Reconcile: %v", err) |
| } |
| + // The per-box health pass follows the reconcile, as domainsLoop runs it (#303). |
| + if err := domains.StampHealth(ctx, reg, dcfg, nil); err != nil { |
| + t.Fatalf("domains.StampHealth: %v", err) |
| + } |
| platformHost := "echoapp." + acctSqid + ".oncarlos.test" |
| prt, err := reg.Get(platformHost) |
| if err != nil { |
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index 235b09a5e..dae29e6bc 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -406,6 +406,43 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| } |
| routes = kept |
| + // Sections 4 and 5 — the per-box health stamps — live in StampHealth |
| + // (#303): they write THIS box's registry and must run on every box, not |
| + // only on the housekeeper this pass is gated to. The agent runs it once |
| + // per tick after this pass, on every box; this pass does not, so the |
| + // housekeeper stamps once like everyone else. |
| + return nil |
| +} |
| + |
| +// StampHealth is the per-box half of the domains pass: DNS health (section |
| +// 4, "pointing"/"elsewhere"/"" on routes and aliases) and TLS health |
| +// (section 5, cert_expires_at). Both stamp the LOCAL registry from a local |
| +// view — this box's resolver, this box's autocert cache — so they are |
| +// every box's job on every tick, with no lease involved: gating them to |
| +// the housekeeper (#289) froze every other box's stamps (#303), and the |
| +// edges-file publisher's "pointing" bound (#287) needs the HOME box's own |
| +// row stamped. |
| +// |
| +// Housekeeper or not, the stamps are what the console shows: a home box's |
| +// status snapshot carries them, and a non-housekeeper home box would show |
| +// every attached hostname as un-pointing forever (#303) — and the edges-file |
| +// publisher's "pointing" bound (#287) reads the home box's own row. |
| +// |
| +// routes may be nil, in which case the registry is listed here. Inert with |
| +// no AppsDomain, per Config's contract. |
| +func StampHealth(ctx context.Context, reg *registry.Registry, cfg Config, routes []registry.Route) error { |
| + if cfg.AppsDomain == "" { |
| + return nil |
| + } |
| + if cfg.Now == nil { |
| + cfg.Now = time.Now |
| + } |
| + if routes == nil { |
| + var err error |
| + if routes, err = reg.List(); err != nil { |
| + return fmt.Errorf("domains: list routes: %w", err) |
| + } |
| + } |
| // 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 |
| @@ -413,6 +450,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // AcmeDir configured pays no aliases query at all. |
| var aliases []registry.RouteAlias |
| if cfg.Resolve != nil || cfg.AcmeDir != "" { |
| + var err error |
| aliases, err = reg.ListAliases() |
| if err != nil { |
| return fmt.Errorf("domains: list aliases: %w", err) |
| @@ -426,14 +464,27 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| edge[ip] = true |
| } |
| now := cfg.Now().Unix() |
| + // A resolver failure is UNKNOWN (""), and unknown never overwrites a |
| + // verdict: one bad DNS tick must not flip a working domain to |
| + // "never checked" — the edges-file publisher gates on "pointing" |
| + // (#287), so that flip would unpublish every alias fleet-wide for a |
| + // cadence. The stamp time is left alone too, so staleness stays |
| + // visible (dns_checked_at stops advancing) rather than being |
| + // laundered into a fresh unknown. |
| for _, rt := range routes { |
| status := dnsStatus(cfg.Resolve, rt.Host, edge) |
| + if status == "" && rt.DNSStatus != "" { |
| + continue |
| + } |
| 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 status == "" && a.DNSStatus != "" { |
| + continue |
| + } |
| if err := reg.SetAliasDNSStatus(a.Alias, status, now); err != nil { |
| log.Printf("domains: stamp dns on alias %s: %v", a.Alias, err) |
| } |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| index ab3b8b652..f90fcdaab 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -541,6 +541,9 @@ func TestReconcileStampsDNS(t *testing.T) { |
| if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| t.Fatal(err) |
| } |
| + if err := domains.StampHealth(ctx, reg, c, nil); err != nil { // as domainsLoop does after the pass |
| + t.Fatal(err) |
| + } |
| now := c.Now().Unix() |
| for host, want := range map[string]string{ |
| "points.test": "pointing", |
| @@ -612,6 +615,9 @@ func TestReconcileStampsCertExpiry(t *testing.T) { |
| if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| t.Fatal(err) |
| } |
| + if err := domains.StampHealth(ctx, reg, c, nil); err != nil { // as domainsLoop does after the pass |
| + t.Fatal(err) |
| + } |
| if got := mustGet(t, reg, "kass.training"); got.CertExpiresAt != notAfter.Unix() { |
| t.Errorf("kass.training cert_expires_at = %d, want %d", got.CertExpiresAt, notAfter.Unix()) |
| } |
| @@ -664,6 +670,9 @@ func TestReconcileStampsAliasDNS(t *testing.T) { |
| if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| t.Fatal(err) |
| } |
| + if err := domains.StampHealth(ctx, reg, c, nil); err != nil { // as domainsLoop does after the pass |
| + t.Fatal(err) |
| + } |
| now := c.Now().Unix() |
| for alias, want := range map[string]string{ |
| "points-alias.test": "pointing", |
| @@ -696,6 +705,9 @@ func TestReconcileStampsAliasCert(t *testing.T) { |
| if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| t.Fatal(err) |
| } |
| + if err := domains.StampHealth(ctx, reg, c, nil); err != nil { // as domainsLoop does after the pass |
| + 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()) |
| } |
| @@ -1183,3 +1195,100 @@ func TestReconcileDoesNotDropRoutesWhenTheProductionFlagIsUnreadable(t *testing. |
| t.Errorf("no refusal logged; log was:\n%s", logged.String()) |
| } |
| } |
| + |
| +// #303: the health stamps are a per-box pass with no lease involved — |
| +// callable on their own, stamping routes AND aliases from this box's view, |
| +// and a no-op with nothing configured. |
| +func TestStampHealthStandsAlone(t *testing.T) { |
| + reg := regWith(t, registry.Route{Host: "app.acct.oncarlos.test", Kind: "service", Addr: "10.0.0.1:1", Status: "ready"}) |
| + if err := reg.UpsertAlias(registry.RouteAlias{Alias: "www.customer.test", Host: "app.acct.oncarlos.test", Account: "acct", App: "app"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg := domains.Config{ |
| + AppsDomain: "oncarlos.test", |
| + EdgeIPs: []string{"203.0.113.7"}, |
| + Resolve: func(host string) ([]string, error) { |
| + if host == "www.customer.test" { |
| + return []string{"203.0.113.7"}, nil |
| + } |
| + return []string{"198.51.100.1"}, nil |
| + }, |
| + } |
| + if err := domains.StampHealth(context.Background(), reg, cfg, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "app.acct.oncarlos.test"); got.DNSStatus != "elsewhere" { |
| + t.Errorf("route dns_status = %q; want elsewhere", got.DNSStatus) |
| + } |
| + aliases, err := reg.ListAliases() |
| + if err != nil || len(aliases) != 1 || aliases[0].DNSStatus != "pointing" { |
| + t.Errorf("alias dns_status: %+v, %v; want pointing", aliases, err) |
| + } |
| + // Nothing configured: nothing stamped, no error. |
| + reg2 := regWith(t, registry.Route{Host: "x.test", Kind: "service", Addr: "10.0.0.1:1", Status: "ready"}) |
| + if err := domains.StampHealth(context.Background(), reg2, domains.Config{AppsDomain: "oncarlos.test"}, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg2, "x.test"); got.DNSStatus != "" || got.DNSCheckedAt != 0 { |
| + t.Errorf("unconfigured pass stamped %+v", got) |
| + } |
| +} |
| + |
| +// A resolver failure never overwrites a verdict: the last known verdict |
| +// and its stamp time stay, so a bad DNS tick cannot unpublish a working |
| +// custom domain (#287's gate) or launder staleness into "never checked". |
| +func TestStampHealthHoldsLastVerdictOnResolverFailure(t *testing.T) { |
| + reg := regWith(t, registry.Route{Host: "app.acct.oncarlos.test", Kind: "service", Addr: "10.0.0.1:1", Status: "ready"}) |
| + if err := reg.UpsertAlias(registry.RouteAlias{Alias: "www.customer.test", Host: "app.acct.oncarlos.test", Account: "acct", App: "app"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + good := domains.Config{AppsDomain: "oncarlos.test", EdgeIPs: []string{"203.0.113.7"}, |
| + Resolve: func(string) ([]string, error) { return []string{"203.0.113.7"}, nil }, |
| + Now: func() time.Time { return time.Unix(1000, 0) }} |
| + if err := domains.StampHealth(context.Background(), reg, good, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + broken := good |
| + broken.Resolve = func(string) ([]string, error) { return nil, errors.New("SERVFAIL") } |
| + broken.Now = func() time.Time { return time.Unix(2000, 0) } |
| + if err := domains.StampHealth(context.Background(), reg, broken, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "app.acct.oncarlos.test"); got.DNSStatus != "pointing" || got.DNSCheckedAt != 1000 { |
| + t.Errorf("route after resolver failure = %q @%d; want pointing @1000", got.DNSStatus, got.DNSCheckedAt) |
| + } |
| + aliases, _ := reg.ListAliases() |
| + if len(aliases) != 1 || aliases[0].DNSStatus != "pointing" || aliases[0].DNSCheckedAt != 1000 { |
| + t.Errorf("alias after resolver failure = %+v; want pointing @1000", aliases) |
| + } |
| + // A never-checked row still records unknown, so "unknown" stays honest. |
| + fresh := regWith(t, registry.Route{Host: "new.acct.oncarlos.test", Kind: "service", Addr: "10.0.0.1:1", Status: "ready"}) |
| + if err := domains.StampHealth(context.Background(), fresh, broken, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, fresh, "new.acct.oncarlos.test"); got.DNSStatus != "" || got.DNSCheckedAt != 2000 { |
| + t.Errorf("fresh row = %q @%d; want \"\" @2000", got.DNSStatus, got.DNSCheckedAt) |
| + } |
| +} |
| + |
| +// Back-to-back passes are idempotent: a second StampHealth with the same |
| +// view changes nothing but dns_checked_at. |
| +func TestStampHealthIsIdempotent(t *testing.T) { |
| + reg := regWith(t, registry.Route{Host: "app.acct.oncarlos.test", Kind: "service", Addr: "10.0.0.1:1", Status: "ready"}) |
| + c := cfg(t) |
| + for i := 0; i < 2; i++ { |
| + if err := domains.StampHealth(context.Background(), reg, c, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if got := mustGet(t, reg, "app.acct.oncarlos.test"); got.DNSStatus != "pointing" || got.DNSCheckedAt != c.Now().Unix() { |
| + t.Errorf("after two passes: %q @%d", got.DNSStatus, got.DNSCheckedAt) |
| + } |
| + // And inert with no apps domain, per Config's contract. |
| + if err := domains.StampHealth(context.Background(), reg, domains.Config{Resolve: c.Resolve, Now: func() time.Time { return time.Unix(9, 0) }}, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "app.acct.oncarlos.test"); got.DNSCheckedAt == 9 { |
| + t.Error("a pass with no AppsDomain wrote a stamp") |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 4f12d4a93..298afdb68 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -1629,7 +1629,7 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| log.Printf("agent: domains loop disabled: %v", err) |
| return |
| } |
| - log.Printf("agent: domains loop armed (5min + jitter), apps domain %s, edge IPs %v — reconciles only while this box holds the housekeeper lease", cfg.AppsDomain, cfg.EdgeIPs) |
| + log.Printf("agent: domains loop armed (5min + jitter), apps domain %s, edge IPs %v — health stamps every tick; claims reconcile only while this box holds the housekeeper lease", cfg.AppsDomain, cfg.EdgeIPs) |
| // One line, and it must name the REAL cause: a typo'd key and an absent |
| // one are different problems with different fixes, and this is the only |
| // place an operator learns that purging is not happening. |
| @@ -1641,22 +1641,37 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| duty := domainsDuty(func(ctx context.Context) error { |
| return domains.Reconcile(ctx, store, reg, cfg) |
| }) |
| + fleetBox := shouldSkipPublisherElection(os.Getenv) |
| delay := time.Minute |
| for { |
| time.Sleep(delay) |
| delay = 5*time.Minute + rand.N(time.Minute) |
| + // The deployment-global half, gated; then the per-box half on EVERY |
| + // platform box (#303): DNS and TLS health stamp this box's own |
| + // registry from this box's own view, no lease involved, ONCE per |
| + // tick — after the duties so a host section 3 just minted is |
| + // stamped in the same tick it appeared. Never on a fleet box: it |
| + // never contends for the lease for the same reason (housekeeperNever), |
| + // and its EdgeIPs fall back to its own address, which would judge |
| + // every row "elsewhere". |
| runHousekeeperDuties(housekeeperGate, duty) |
| + if !fleetBox { |
| + if err := domains.StampHealth(ctx, reg, cfg, nil); err != nil { |
| + log.Printf("agent: domains health: %v", err) |
| + } |
| + } |
| } |
| } |
| // domainsDuty wraps one domains reconcile pass as a housekeeper duty. The |
| -// pass is deployment-wide state — custom-domain `pointing` stamps, cert |
| -// expiry stamps, purging trashed apps — with one correct writer, and since |
| -// multi-region (spec 2026-08-20) every platform box in a deployment carries |
| -// PLATFORM_APPS_DOMAIN (the per-account wildcards and the steering probe |
| -// need it), so an ungated loop would be N reconcilers racing over the same |
| -// records, each judging `pointing` against its OWN edge-IP list. Gating on |
| -// the lease is what makes the env safe to set fleet-wide. Single-box |
| +// pass is deployment-wide state — materializing routes from claims, minting |
| +// platform-domain and canary hosts, purging trashed apps — with one correct |
| +// writer, and since multi-region (spec 2026-08-20) every platform box in a |
| +// deployment carries PLATFORM_APPS_DOMAIN (the per-account wildcards and the |
| +// steering probe need it), so an ungated loop would be N reconcilers racing |
| +// over the same records. Gating on the lease is what makes the env safe to |
| +// set fleet-wide. (The DNS/TLS health stamps are NOT in this pass: they are |
| +// per-box, domains.StampHealth, run by domainsLoop on every box — #303.) Single-box |
| // deployments are unchanged: the one box always holds the lease. A box that |
| // never contends (a fleet box) never runs the pass — which is right, the |
| // pass is a platform-box job. |