| index fab5d5121..4f12d4a93 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", cfg.AppsDomain, cfg.EdgeIPs) |
| + 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) |
| // 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. |
| @@ -1638,16 +1638,37 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| } else if cfg.ReleasePub == nil { |
| log.Printf("agent: domains loop auto-purge disabled: CARLOS_RELEASE_PUBKEY is not set — trashed apps will be hidden but never destroyed") |
| } |
| + duty := domainsDuty(func(ctx context.Context) error { |
| + return domains.Reconcile(ctx, store, reg, cfg) |
| + }) |
| delay := time.Minute |
| for { |
| time.Sleep(delay) |
| delay = 5*time.Minute + rand.N(time.Minute) |
| - if err := domains.Reconcile(ctx, store, reg, cfg); err != nil { |
| - log.Printf("agent: domains pass: %v", err) |
| - } |
| + runHousekeeperDuties(housekeeperGate, duty) |
| } |
| } |
| |
| +// 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 |
| +// 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. |
| +func domainsDuty(pass func(context.Context) error) housekeeperDuty { |
| + return housekeeperDuty{"domains reconcile", func(ctx context.Context) error { |
| + if pass == nil { |
| + return nil |
| + } |
| + return pass(ctx) |
| + }} |
| +} |
| + |
| // instancesLoop runs the instance-provisioning reconcile pass forever on the |
| // same cadence as domainsLoop (5min + jitter, first tick after 1 minute) — |
| // turning bucket instance records into registry routes for THIS agent's |