domains: pointing is judged against the deployment's live edges, not one box's env
PLATFORM_EDGE_IPS is read once at box start. Under latency steering a correctly-pointed customer domain resolves to whichever region's edge is nearest, so a boot-frozen set reads a healthy domain as "elsewhere" the moment another region arms. This matters more now than when it was written: main's #287 publisher only puts an alias into control/edges/<box>.json once it is stamped "pointing". A wrong "elsewhere" no longer just mis-reports — it withholds the hostname from every edge, which is why that code already carries a log line asking whether PLATFORM_EDGE_IPS lists every armed edge. It now does not have to: the set is folded per pass from every live box's own edges file, unioned with the env value so a deployment whose bucket is unreadable degrades to exactly the old behaviour and never below it. Liveness comes from the box status snapshots, never from Generation — Generation only moves on write and the publisher's change gate suppresses unchanged republishes, so every healthy box would read stale. The join is on the box name, which is pinned end-to-end by TestEdgesFileBoxNameJoinsTheStatusSnapshotLabel. An empty fold stands down rather than stamping: marking every host "elsewhere" at once would be a fleet-wide red raised by a failed bucket read, not by anyone's DNS. That is the set-level case of the rule main already applies per host.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 files changed,
+711
−75
e2e_test.go+1 −1internal/console/edgefold_test.go+119 −0internal/console/server.go+107 −1internal/console/view.go+9 −4internal/domains/reconcile.go+49 −28internal/domains/reconcile_test.go+73 −3internal/edgesfile/fold.go+74 −0internal/edgesfile/fold_test.go+146 −0internal/host/agent.go+56 −19internal/host/edgesfile_test.go+40 −0internal/host/host_test.go+37 −19
diff --git a/e2e_test.go b/e2e_test.go| index ac74c37fc..3b9e149aa 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -420,7 +420,7 @@ func TestEndToEndSmoke(t *testing.T) { |
| acctSqid := release.OpsAccount |
| dcfg := domains.Config{ |
| AppsDomain: "oncarlos.test", |
| - EdgeIPs: []string{"203.0.113.7"}, |
| + EdgeIPs: func() []string { return []string{"203.0.113.7"} }, |
| Resolve: func(host string) ([]string, error) { return []string{"203.0.113.7"}, nil }, |
| Now: time.Now, |
| } |
diff --git a/internal/console/edgefold_test.go b/internal/console/edgefold_test.go| new file mode 100644 |
| index 000000000..04c153bcb |
| --- /dev/null |
| +++ b/internal/console/edgefold_test.go |
| @@ -0,0 +1,119 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// The guidance a member is shown must name EVERY armed edge, not just the |
| +// ones this console's own PLATFORM_EDGE_IPS happens to list — otherwise a |
| +// multi-region deployment tells people to point at one region and then grades |
| +// them against all of them. Union, configured addresses first, no duplicates. |
| +func TestDNSTargetsUnionsTheConfiguredListWithTheFold(t *testing.T) { |
| + s := &Server{ |
| + cfg: Config{EdgeIPs: []string{"203.0.113.7"}}, |
| + foldEdgeIPs: func(context.Context) []string { return []string{"198.51.100.9", "203.0.113.7"} }, |
| + } |
| + got := s.dnsTargets() |
| + if !reflect.DeepEqual(got.EdgeIPs, []string{"203.0.113.7", "198.51.100.9"}) { |
| + t.Fatalf("EdgeIPs = %v, want the configured list first, then the fold's extras, deduped", got.EdgeIPs) |
| + } |
| +} |
| + |
| +// dnsTargets is called once PER CLAIM inside the domains-list loop. An |
| +// uncached fold would put a box-snapshot sweep plus one Get per edges file |
| +// behind every row of every listing — the cost discipline |
| +// domainListCNAMELookupCap already established for this door. |
| +func TestEdgeFoldIsCachedForTheTTL(t *testing.T) { |
| + folds := 0 |
| + s := &Server{foldEdgeIPs: func(context.Context) []string { |
| + folds++ |
| + return []string{"198.51.100.9"} |
| + }} |
| + for i := 0; i < 5; i++ { |
| + s.dnsTargets() |
| + } |
| + if folds != 1 { |
| + t.Fatalf("folded %d times inside the TTL, want 1", folds) |
| + } |
| + |
| + // Past the TTL the next reader refolds — an edge that armed a minute ago |
| + // has to become visible without a console restart. |
| + s.edgeFold.mu.Lock() |
| + s.edgeFold.at = time.Now().Add(-edgeFoldTTL - time.Second) |
| + s.edgeFold.mu.Unlock() |
| + if got := s.dnsTargets(); folds != 2 || len(got.EdgeIPs) != 1 { |
| + t.Fatalf("after the TTL: folds = %d, EdgeIPs = %v, want a refold", folds, got.EdgeIPs) |
| + } |
| +} |
| + |
| +// A fold that comes back empty is a bucket that could not be read this |
| +// minute far more often than it is a deployment that lost every edge, so the |
| +// previous answer stands rather than being emptied — narrowing the guidance |
| +// every member is shown on the strength of one failed read is the worse |
| +// error. The timestamp still moves, so a persistent outage costs one bounded |
| +// attempt per TTL, not one per request. |
| +func TestEdgeFoldKeepsTheLastAnswerWhenAFoldFails(t *testing.T) { |
| + folds := 0 |
| + s := &Server{foldEdgeIPs: func(context.Context) []string { |
| + folds++ |
| + if folds == 1 { |
| + return []string{"198.51.100.9"} |
| + } |
| + return nil // the store is down |
| + }} |
| + if got := s.dnsTargets(); len(got.EdgeIPs) != 1 { |
| + t.Fatalf("first fold: EdgeIPs = %v", got.EdgeIPs) |
| + } |
| + s.edgeFold.mu.Lock() |
| + s.edgeFold.at = time.Now().Add(-edgeFoldTTL - time.Second) |
| + s.edgeFold.mu.Unlock() |
| + got := s.dnsTargets() |
| + if folds != 2 { |
| + t.Fatalf("folds = %d, want the stale entry to have been retried", folds) |
| + } |
| + if !reflect.DeepEqual(got.EdgeIPs, []string{"198.51.100.9"}) { |
| + t.Fatalf("EdgeIPs = %v, want the previous answer left standing", got.EdgeIPs) |
| + } |
| + // And the failed attempt still moved the clock: a console whose bucket is |
| + // down must not refold on every request of every listing. |
| + before := folds |
| + s.dnsTargets() |
| + if folds != before { |
| + t.Fatalf("folds = %d, want the failed attempt to still hold off for the TTL", folds) |
| + } |
| +} |
| + |
| +// The real fold, end to end over a bucket: a live box's published EIP reaches |
| +// the guidance even though nothing ever set it in this console's env. |
| +func TestRealEdgeFoldReadsTheDeploymentsEdgesFiles(t *testing.T) { |
| + ctx := context.Background() |
| + store := release.NewDirStore(t.TempDir()) |
| + if _, err := store.Put(ctx, edgesfile.Key("annie"), |
| + []byte(`{"v":1,"box":"annie","eip":"198.51.100.9","hosts":{}}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := status.WriteBox(ctx, store, status.Box{Label: "annie", GeneratedAt: time.Now()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + s := &Server{cfg: Config{EdgeIPs: []string{"203.0.113.7"}, Releases: store}} |
| + if got := s.dnsTargets(); !reflect.DeepEqual(got.EdgeIPs, []string{"203.0.113.7", "198.51.100.9"}) { |
| + t.Fatalf("EdgeIPs = %v, want the configured address plus the folded one", got.EdgeIPs) |
| + } |
| +} |
| + |
| +// A console with no deployment bucket at all (Releases nil) is a legal |
| +// deployment: it degrades to the configured list rather than panicking or |
| +// emptying the guidance. |
| +func TestRealEdgeFoldWithNoStoreDegradesToTheConfiguredList(t *testing.T) { |
| + s := &Server{cfg: Config{EdgeIPs: []string{"203.0.113.7"}}} |
| + if got := s.dnsTargets(); !reflect.DeepEqual(got.EdgeIPs, []string{"203.0.113.7"}) { |
| + t.Fatalf("EdgeIPs = %v, want the configured list", got.EdgeIPs) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index d3764c96c..23fa6fef6 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -21,6 +21,7 @@ import ( |
| "net/url" |
| "os" |
| "regexp" |
| + "slices" |
| "strings" |
| "sync" |
| "sync/atomic" |
| @@ -32,6 +33,7 @@ import ( |
| "github.com/carlosframework/platform/internal/dnsrec" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/economics" |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/infraview" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| @@ -691,6 +693,45 @@ type Server struct { |
| // TopicArn, which is not a secret, triggers a fresh outbound HTTPS fetch |
| // and full chain verification. Zero value is ready to use. |
| snsCerts snsCertCache |
| + // edgeFold caches the deployment's folded live edge addresses — every |
| + // box's control/edges/<box>.json joined against its status snapshot — |
| + // so the guidance strings can name every armed edge rather than only |
| + // the ones this console's own PLATFORM_EDGE_IPS lists. Cached because |
| + // dnsTargets is on the request path and is called once PER CLAIM inside |
| + // the domains-list loop: an uncached fold would put a LoadBoxes sweep |
| + // plus one Get per edges file behind every row of every listing. Zero |
| + // value is ready to use. |
| + edgeFold edgeFoldCache |
| + // foldEdgeIPs is edgeFoldCache's test seam: nil means the real fold |
| + // over cfg.Releases (s.realFoldEdgeIPs). Tests inject a counter so the |
| + // TTL can be proven without a bucket, the same way storeSizer and |
| + // ceCosts stand in for AWS above. |
| + foldEdgeIPs func(ctx context.Context) []string |
| +} |
| + |
| +// edgeFoldTTL is how long one fold of the deployment's edges files stands |
| +// for. Edges arm and retire on the scale of a deployment, not a request, and |
| +// every box republishes its own file on a 5min+jitter loop — so a minute is |
| +// far below the rate at which the answer can actually change while still |
| +// keeping the domains-list door off the bucket for all but one request a |
| +// minute. |
| +const edgeFoldTTL = time.Minute |
| + |
| +// edgeFoldTimeout bounds one fold, mirroring the agent's own closure. |
| +// dnsTargets takes no context of its own (it is called from deep inside view |
| +// assembly), so without this a hung store would stall the domains-list door |
| +// indefinitely rather than degrading to the configured list. |
| +const edgeFoldTimeout = 30 * time.Second |
| + |
| +// edgeFoldCache is the folded set plus the instant it was folded. The mutex |
| +// is held ACROSS the refresh deliberately: it is what makes concurrent |
| +// callers share one fold instead of stampeding the bucket, and the fold is |
| +// bounded by edgeFoldTimeout so the worst case is a bounded wait, not a |
| +// wedge. |
| +type edgeFoldCache struct { |
| + mu sync.Mutex |
| + at time.Time |
| + ips []string |
| } |
| // dnsTargets is what every custom-domain guidance string in this console is |
| @@ -699,7 +740,72 @@ type Server struct { |
| // page and the /domains API can never disagree about where a member should |
| // point DNS. |
| func (s *Server) dnsTargets() DNSTargets { |
| - return DNSTargets{EdgeIPs: s.cfg.EdgeIPs, SteeringHost: s.steeringHost} |
| + return DNSTargets{EdgeIPs: s.edgeIPs(), SteeringHost: s.steeringHost} |
| +} |
| + |
| +// edgeIPs is the deployment's edge addresses as this console will tell a |
| +// member to point at: the operator's configured list UNIONED with the live |
| +// fold, configured addresses first. |
| +// |
| +// Union, not replacement, in both directions. A console whose bucket read |
| +// fails still offers PLATFORM_EDGE_IPS rather than none — an empty guidance |
| +// string is worse than a partial one. And a multi-region deployment's other |
| +// edges appear even though this console's env var predates them, which is |
| +// the whole point: the same set the domains reconcile pass now judges |
| +// "pointing" against, so guidance and verdict cannot disagree. |
| +func (s *Server) edgeIPs() []string { |
| + out := append([]string(nil), s.cfg.EdgeIPs...) |
| + for _, ip := range s.foldedEdgeIPs() { |
| + if ip != "" && !slices.Contains(out, ip) { |
| + out = append(out, ip) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// foldedEdgeIPs answers the cached fold, refreshing it when it has aged past |
| +// edgeFoldTTL. |
| +// |
| +// A refresh that comes back with NOTHING leaves the previous answer standing: |
| +// an empty fold is a bucket that could not be read this minute far more often |
| +// than it is a deployment that lost every edge, and emptying the set would |
| +// silently narrow the guidance every member is shown. The timestamp still |
| +// moves, so a persistent outage costs one bounded attempt per TTL rather than |
| +// one per request. |
| +func (s *Server) foldedEdgeIPs() []string { |
| + c := &s.edgeFold |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if !c.at.IsZero() && time.Since(c.at) < edgeFoldTTL { |
| + return c.ips |
| + } |
| + fold := s.foldEdgeIPs |
| + if fold == nil { |
| + fold = s.realFoldEdgeIPs |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), edgeFoldTimeout) |
| + defer cancel() |
| + ips := fold(ctx) |
| + c.at = time.Now() |
| + if len(ips) > 0 { |
| + c.ips = ips |
| + } |
| + return c.ips |
| +} |
| + |
| +// realFoldEdgeIPs folds the deployment bucket's edges files. env is nil here, |
| +// not cfg.EdgeIPs: edgeIPs above does the union, so the configured list is |
| +// added exactly once and the cache holds only what was actually read from the |
| +// bucket — which is what lets an empty fold be recognised as a failed read. |
| +// Sorted so a listing's guidance sentence does not reshuffle between calls on |
| +// the store's arbitrary iteration order. |
| +func (s *Server) realFoldEdgeIPs(ctx context.Context) []string { |
| + if s.cfg.Releases == nil { |
| + return nil |
| + } |
| + ips := edgesfile.EdgeIPs(ctx, s.cfg.Releases, nil, time.Now()) |
| + slices.Sort(ips) |
| + return ips |
| } |
| // normalizeSteeringHost lowercases, trims and shape-validates the steering |
diff --git a/internal/console/view.go b/internal/console/view.go| index 43ea3c7e3..c4a5f1215 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -113,14 +113,19 @@ type DomainView struct { |
| // only on a deployment that STEERS (multi-region ingress spec, Part 4) — |
| // the steering hostname a non-apex host may CNAME to instead. |
| type DNSTargets struct { |
| - // EdgeIPs is PLATFORM_EDGE_IPS, read at console start. nil is legal: |
| - // guidance then says "this deployment's edge IP" rather than inventing |
| - // an address. |
| + // EdgeIPs is PLATFORM_EDGE_IPS unioned with the deployment's live fold |
| + // of every box's edges file (Server.edgeIPs). nil is legal: guidance |
| + // then says "this deployment's edge IP" rather than inventing an |
| + // address. |
| // |
| // Once a deployment steers, this list must name EVERY armed edge's EIP, |
| // not just the local box's: the DNS check that stamps "pointing" |
| // compares a resolved address against it, and a steered name resolves |
| - // to whichever edge the resolver is nearest. |
| + // to whichever edge the resolver is nearest. The fold is what makes |
| + // that true without the operator re-setting an env var on every box |
| + // each time a region arms — and it is the SAME fold the domains |
| + // reconcile pass judges "pointing" with, so what a member is told to do |
| + // and what the platform then grades them on cannot drift apart. |
| EdgeIPs []string |
| // SteeringHost is the deployment's steering hostname (edge.<apps-domain>, |
| // itself latency-routed with one record per armed edge). "" — every |
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index dae29e6bc..c7df26c2b 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -27,9 +27,14 @@ type Config struct { |
| // manifests read, zero registry writes — the litestream convention: |
| // unconfigured means the feature does not exist. |
| AppsDomain string |
| - // EdgeIPs are this deployment's edge addresses; a host resolving to any |
| - // of them is "pointing". |
| - EdgeIPs []string |
| + // EdgeIPs answers this deployment's current edge addresses; a host |
| + // resolving to any of them is "pointing". A FUNC, not a slice: the set |
| + // moves at runtime as edges arm and retire, while the agent's config is |
| + // built once at loop arm — a boot-frozen list is exactly the bug this |
| + // replaced (a correctly-pointed domain steered to another region's edge |
| + // read as pointing elsewhere). nil, or a func returning nothing, leaves |
| + // DNS stamping standing down for that pass — see section 4. |
| + EdgeIPs func() []string |
| // Resolve is the DNS seam (net resolver in production, a fake in |
| // tests); nil skips DNS stamping entirely. |
| Resolve func(host string) ([]string, error) |
| @@ -458,35 +463,51 @@ func StampHealth(ctx context.Context, reg *registry.Registry, cfg Config, routes |
| } |
| // --- 4. DNS health -------------------------------------------------------- |
| - if cfg.Resolve != nil { |
| + // The edge set is asked for ONCE PER PASS, not once per box start: under |
| + // latency steering a customer's correctly-pointed domain resolves to |
| + // whichever region's edge the resolver is nearest, so the comparison set |
| + // has to be the deployment's live one. |
| + if cfg.Resolve != nil && cfg.EdgeIPs != nil { |
| edge := map[string]bool{} |
| - for _, ip := range cfg.EdgeIPs { |
| + for _, ip := range cfg.EdgeIPs() { |
| 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 |
| + // Load-bearing: stamping against an empty set would mark every host |
| + // in the deployment `elsewhere` at once — a fleet-wide lying red |
| + // raised by a bucket read that failed, not by anyone's DNS. Unknown |
| + // is not a verdict this pass is allowed to guess at, so it stands |
| + // down and leaves the previous stamps alone until it knows again. |
| + // |
| + // This is the SET-level case of the same rule the per-host loop |
| + // below applies: what we do not know, we do not stamp. |
| + if len(edge) == 0 { |
| + log.Printf("domains: no edge addresses known this pass — DNS stamping stands down rather than stamping every host elsewhere") |
| + } else { |
| + 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) |
| + } |
| } |
| - if err := reg.SetAliasDNSStatus(a.Alias, status, now); err != nil { |
| - log.Printf("domains: stamp dns on alias %s: %v", a.Alias, 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 f90fcdaab..b6f2afa34 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -73,11 +73,16 @@ func putKey(t *testing.T, s release.Store, key string) { |
| // cfg is the standard reconcile config: apps domain set, a fake resolver |
| // saying every host points at the edge, DNS/TLS seams otherwise defaulted. |
| +// |
| +// EdgeIPs is a func because the live set moves under the pass (in production |
| +// it folds every box's published edges file each pass); a fixture that |
| +// answers the same one address every time keeps every existing assertion |
| +// here reading exactly as it did when the field was a slice. |
| func cfg(t *testing.T) domains.Config { |
| t.Helper() |
| return domains.Config{ |
| AppsDomain: "oncarlos.test", |
| - EdgeIPs: []string{"203.0.113.7"}, |
| + EdgeIPs: func() []string { return []string{"203.0.113.7"} }, |
| Resolve: func(host string) ([]string, error) { return []string{"203.0.113.7"}, nil }, |
| Now: func() time.Time { return time.Unix(1754000000, 0) }, |
| } |
| @@ -560,6 +565,71 @@ func TestReconcileStampsDNS(t *testing.T) { |
| } |
| } |
| +// The edge set is now asked for once PER PASS, not fixed at box start: under |
| +// latency steering a correctly-pointed customer domain resolves to whichever |
| +// region's edge is nearest, so a set frozen at boot reads a healthy domain as |
| +// "elsewhere" the moment another region arms. This pins that the pass reads |
| +// the CURRENT answer — a second pass with a widened set restamps. |
| +func TestReconcileReadsTheEdgeSetEveryPass(t *testing.T) { |
| + reg := regWith(t, |
| + registry.Route{Host: "faraway.test", Kind: "static", Status: "ready"}, |
| + ) |
| + c := cfg(t) |
| + c.Resolve = func(host string) ([]string, error) { return []string{"198.51.100.9"}, nil } |
| + calls := 0 |
| + // A second region's edge arms between the two passes. |
| + c.EdgeIPs = func() []string { |
| + calls++ |
| + if calls > 1 { |
| + return []string{"203.0.113.7", "198.51.100.9"} |
| + } |
| + return []string{"203.0.113.7"} |
| + } |
| + if err := domains.StampHealth(ctx, reg, c, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "faraway.test"); got.DNSStatus != "elsewhere" { |
| + t.Fatalf("pass 1: dns_status = %q, want elsewhere — that edge is not armed yet", got.DNSStatus) |
| + } |
| + if err := domains.StampHealth(ctx, reg, c, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "faraway.test"); got.DNSStatus != "pointing" { |
| + t.Fatalf("pass 2: dns_status = %q, want pointing — the set is read per pass, not frozen at arm", got.DNSStatus) |
| + } |
| +} |
| + |
| +// A pass that knows NO edge addresses stands down rather than stamping. |
| +// Stamping against an empty set would mark every host in the deployment |
| +// "elsewhere" at once — a fleet-wide lying red raised by a failed bucket |
| +// read, not by anybody's DNS. Existing stamps are left exactly as they were: |
| +// the last thing this pass actually knew beats a verdict it had to guess. |
| +func TestReconcileStandsDownWithNoEdgeIPs(t *testing.T) { |
| + reg := regWith(t, |
| + registry.Route{Host: "points.test", Kind: "static", Status: "ready"}, |
| + ) |
| + if err := domains.StampHealth(ctx, reg, cfg(t), nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "points.test"); got.DNSStatus != "pointing" { |
| + t.Fatalf("setup: dns_status = %q, want pointing", got.DNSStatus) |
| + } |
| + |
| + for name, edge := range map[string]func() []string{ |
| + "an empty fold": func() []string { return nil }, |
| + "no func at all": nil, |
| + } { |
| + c := cfg(t) |
| + c.EdgeIPs = edge |
| + if err := domains.StampHealth(ctx, reg, c, nil); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + if got := mustGet(t, reg, "points.test"); got.DNSStatus != "pointing" { |
| + t.Errorf("%s: dns_status = %q, want the previous stamp left standing", name, got.DNSStatus) |
| + } |
| + } |
| +} |
| + |
| // writeCertBundle writes what autocert's cachePut writes under DirCache key |
| // <host>: the private key PEM block first, then the CERTIFICATE chain — so |
| // the parser is exercised on skipping the key block. |
| @@ -1206,7 +1276,7 @@ func TestStampHealthStandsAlone(t *testing.T) { |
| } |
| cfg := domains.Config{ |
| AppsDomain: "oncarlos.test", |
| - EdgeIPs: []string{"203.0.113.7"}, |
| + EdgeIPs: func() []string { return []string{"203.0.113.7"} }, |
| Resolve: func(host string) ([]string, error) { |
| if host == "www.customer.test" { |
| return []string{"203.0.113.7"}, nil |
| @@ -1242,7 +1312,7 @@ func TestStampHealthHoldsLastVerdictOnResolverFailure(t *testing.T) { |
| 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"}, |
| + good := domains.Config{AppsDomain: "oncarlos.test", EdgeIPs: func() []string { return []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 { |
diff --git a/internal/edgesfile/fold.go b/internal/edgesfile/fold.go| new file mode 100644 |
| index 000000000..971f50289 |
| --- /dev/null |
| +++ b/internal/edgesfile/fold.go |
| @@ -0,0 +1,74 @@ |
| +package edgesfile |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "slices" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// EdgeIPs is the deployment's live edge address set: every EIP published by a |
| +// box whose STATUS SNAPSHOT is still fresh, unioned with env (the operator's |
| +// PLATFORM_EDGE_IPS). |
| +// |
| +// The union is the whole failure story. Nothing here deletes anything, so the |
| +// fold can only WIDEN what counts as "pointing at us" — which means a short |
| +// fold, an unreadable store, or a deployment with no snapshots at all |
| +// degrades to exactly the pre-fold behaviour (env only) and never below it. |
| +// Contrast internal/steering, which fails CLOSED on a short fold because a |
| +// short fold THERE deletes DNS records. |
| +// |
| +// Liveness is the status snapshot, never the edges file's own Generation: |
| +// Generation moves only when the file is WRITTEN, and the writer's change |
| +// gate exists precisely so an unchanged file is never republished — so a |
| +// healthy box's Generation is arbitrarily old and would fail any age bound. |
| +// statusTick's WriteBox is unconditional every tick, so BoxStale is a real |
| +// liveness signal. Without one, a retired box's lingering edges file would |
| +// keep a DEAD customer domain reading "pointing" forever. |
| +func EdgeIPs(ctx context.Context, s release.Store, env []string, now time.Time) []string { |
| + out := append([]string(nil), env...) |
| + add := func(ip string) { |
| + if ip != "" && !slices.Contains(out, ip) { |
| + out = append(out, ip) |
| + } |
| + } |
| + if s == nil { |
| + return out |
| + } |
| + live := map[string]bool{} |
| + boxes, err := status.LoadBoxes(ctx, s) |
| + if err != nil { |
| + log.Printf("edgesfile: could not load box snapshots (%v) — edge set falls back to the configured list", err) |
| + return out |
| + } |
| + for _, b := range boxes { |
| + if !status.BoxStale(b, now, status.SnapshotPeriod) { |
| + live[b.Label] = true |
| + } |
| + } |
| + keys, err := s.List(ctx, Prefix) |
| + if err != nil { |
| + log.Printf("edgesfile: could not list %s (%v) — edge set falls back to the configured list", Prefix, err) |
| + return out |
| + } |
| + for _, key := range keys { |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("edgesfile: %s: %v", key, err) |
| + continue |
| + } |
| + f, err := Parse(body) |
| + if err != nil { |
| + log.Printf("edgesfile: %s: %v", key, err) |
| + continue |
| + } |
| + if !live[f.Box] { |
| + continue // no fresh snapshot: not evidence of a live edge |
| + } |
| + add(f.EIP) |
| + } |
| + return out |
| +} |
diff --git a/internal/edgesfile/fold_test.go b/internal/edgesfile/fold_test.go| new file mode 100644 |
| index 000000000..e112960b8 |
| --- /dev/null |
| +++ b/internal/edgesfile/fold_test.go |
| @@ -0,0 +1,146 @@ |
| +package edgesfile |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "slices" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// The fold can only WIDEN: a box the deployment can see adds its EIP, and the |
| +// operator's env list is always included. That is the safe direction — |
| +// nothing here deletes, and a short fold must degrade to today's behaviour |
| +// (env only), never below it. |
| +func TestEdgeIPsUnionsTheFoldWithTheEnvList(t *testing.T) { |
| + s := foldTestStore(t) |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{}}`) |
| + putEdgesFile(t, s, "betty", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{}}`) |
| + putFreshBox(t, s, "annie", now) |
| + putFreshBox(t, s, "betty", now) |
| + |
| + got := EdgeIPs(context.Background(), s, []string{"9.9.9.9"}, now) |
| + slices.Sort(got) |
| + want := []string{"1.1.1.1", "2.2.2.2", "9.9.9.9"} |
| + if !slices.Equal(got, want) { |
| + t.Fatalf("EdgeIPs = %v, want %v", got, want) |
| + } |
| +} |
| + |
| +// A box whose STATUS SNAPSHOT has gone stale drops out — that is what keeps a |
| +// retired box's lingering edges file from holding a dead customer domain at |
| +// "pointing" forever (a lying green). Generation is NOT the signal: an |
| +// unchanged file is never republished, so a healthy box's Generation is |
| +// arbitrarily old. |
| +func TestEdgeIPsDropsABoxWhoseSnapshotIsStale(t *testing.T) { |
| + s := foldTestStore(t) |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{}}`) |
| + putEdgesFile(t, s, "retired", `{"v":1,"box":"retired","eip":"3.3.3.3","hosts":{}}`) |
| + putFreshBox(t, s, "annie", now) |
| + putFreshBox(t, s, "retired", now.Add(-72*time.Hour)) |
| + |
| + got := EdgeIPs(context.Background(), s, nil, now) |
| + if slices.Contains(got, "3.3.3.3") { |
| + t.Fatalf("EdgeIPs = %v, want the retired box's EIP dropped", got) |
| + } |
| + if !slices.Contains(got, "1.1.1.1") { |
| + t.Fatalf("EdgeIPs = %v, want the live box kept", got) |
| + } |
| +} |
| + |
| +// An edges file with no matching status snapshot at all is not evidence of |
| +// liveness. Same treatment as a stale one. |
| +func TestEdgeIPsDropsABoxWithNoSnapshot(t *testing.T) { |
| + s := foldTestStore(t) |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + putEdgesFile(t, s, "ghost", `{"v":1,"box":"ghost","eip":"4.4.4.4","hosts":{}}`) |
| + if got := EdgeIPs(context.Background(), s, []string{"9.9.9.9"}, now); !slices.Equal(got, []string{"9.9.9.9"}) { |
| + t.Fatalf("EdgeIPs = %v, want env only", got) |
| + } |
| +} |
| + |
| +// A store that cannot be listed degrades to the env list — never to empty. An |
| +// empty edge set would stamp every custom domain "elsewhere" at once. |
| +func TestEdgeIPsFallsBackToEnvWhenTheStoreFails(t *testing.T) { |
| + s := brokenStore{} |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + if got := EdgeIPs(context.Background(), s, []string{"9.9.9.9"}, now); !slices.Equal(got, []string{"9.9.9.9"}) { |
| + t.Fatalf("EdgeIPs = %v, want the env list on a store failure", got) |
| + } |
| +} |
| + |
| +// The fixtures above call now "fresh" and now-72h "stale" in their own |
| +// words; this asserts the RULE agrees, so a change to status.SnapshotPeriod |
| +// or to BoxStale's factor turns this test red rather than quietly turning |
| +// the two tests above into assertions about nothing. |
| +func TestFoldFixtureAgreesWithTheStalenessRule(t *testing.T) { |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + if status.BoxStale(status.Box{GeneratedAt: now}, now, status.SnapshotPeriod) { |
| + t.Error("a snapshot written this instant must read as fresh") |
| + } |
| + if !status.BoxStale(status.Box{GeneratedAt: now.Add(-72 * time.Hour)}, now, status.SnapshotPeriod) { |
| + t.Error("a snapshot 72h old must read as stale — the retired-box fixture depends on it") |
| + } |
| +} |
| + |
| +// A box publishing no EIP at all (readPublicIP never answered) contributes |
| +// nothing rather than an empty string, which would match no resolution and |
| +// only pollute the guidance strings the console builds from the same set. |
| +func TestEdgeIPsSkipsABoxWithNoAddress(t *testing.T) { |
| + s := foldTestStore(t) |
| + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) |
| + putEdgesFile(t, s, "annie", `{"v":1,"box":"annie","eip":"","hosts":{}}`) |
| + putFreshBox(t, s, "annie", now) |
| + if got := EdgeIPs(context.Background(), s, nil, now); len(got) != 0 { |
| + t.Fatalf("EdgeIPs = %v, want nothing — a blank EIP is not an address", got) |
| + } |
| +} |
| + |
| +// ---- fixtures --------------------------------------------------------------- |
| + |
| +// foldTestStore is a real DirStore over a temp dir: the fold reads through |
| +// release.Store and status.LoadBoxes, and a hand-rolled fake would have to |
| +// re-implement both prefixes' listing semantics to say anything true. |
| +func foldTestStore(t *testing.T) release.Store { |
| + t.Helper() |
| + return release.NewDirStore(t.TempDir()) |
| +} |
| + |
| +func putEdgesFile(t *testing.T, s release.Store, box, body string) { |
| + t.Helper() |
| + if _, err := s.Put(context.Background(), Key(box), []byte(body), ""); err != nil { |
| + t.Fatalf("put edges file for %s: %v", box, err) |
| + } |
| +} |
| + |
| +// putFreshBox writes one box snapshot through status.WriteBox — the SAME |
| +// writer statusTick uses — so the key derivation and the Label field the |
| +// fold joins on cannot drift from production. at is the snapshot's |
| +// GeneratedAt: the caller decides whether that reads as fresh or stale, and |
| +// TestFoldFixtureAgreesWithTheStalenessRule pins both readings. |
| +func putFreshBox(t *testing.T, s release.Store, label string, at time.Time) { |
| + t.Helper() |
| + if err := status.WriteBox(context.Background(), s, status.Box{Label: label, GeneratedAt: at}); err != nil { |
| + t.Fatalf("write box snapshot for %s: %v", label, err) |
| + } |
| +} |
| + |
| +// brokenStore is a store whose every read fails — the bucket-is-down case. |
| +type brokenStore struct{} |
| + |
| +var errBrokenStore = errors.New("store is down") |
| + |
| +func (brokenStore) Get(context.Context, string) ([]byte, string, error) { |
| + return nil, "", errBrokenStore |
| +} |
| +func (brokenStore) Put(context.Context, string, []byte, string) (string, error) { |
| + return "", errBrokenStore |
| +} |
| +func (brokenStore) List(context.Context, string) ([]string, error) { return nil, errBrokenStore } |
| +func (brokenStore) ListDirs(context.Context, string) ([]string, error) { return nil, errBrokenStore } |
| +func (brokenStore) Delete(context.Context, string) error { return errBrokenStore } |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 3b347296c..35a8a8be6 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -126,8 +126,15 @@ |
| // shape = delegation off, per-host and direct DNS-01 continue |
| // - PLATFORM_EDGE_IPS — comma-separated edge IPs custom-domain DNS checks |
| // compare against. Default: this box's public IPv4 from IMDS |
| -// (best-effort; if neither is knowable, DNS stamping stands down |
| -// entirely rather than guess "elsewhere" about every host). |
| +// (best-effort). It is no longer the WHOLE comparison set: each pass |
| +// unions it with the deployment's LIVE edges — every box's published |
| +// control/edges/<box>.json whose status snapshot is still fresh |
| +// (edgesfile.EdgeIPs) — because under latency steering a correctly- |
| +// pointed domain resolves to whichever region's edge is nearest, and |
| +// one box's boot-frozen list reads that as "elsewhere". The union only |
| +// ever WIDENS, so an unreadable bucket degrades to exactly this |
| +// variable. A pass that ends up knowing no addresses at all stands |
| +// down rather than guessing "elsewhere" about every host. |
| package host |
| import ( |
| @@ -161,6 +168,7 @@ import ( |
| "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/fleetapi" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -1425,11 +1433,22 @@ func peekAcmeCache(args []string) string { |
| // domainsTickConfig builds one reconcile pass's Config from the environment |
| // — pure (getenv + the already-read public IP injected) so the whole env |
| // contract is testable without t.Setenv. PLATFORM_APPS_DOMAIN unset returns |
| -// the zero Config, on which Reconcile is inert by contract. When no edge IP |
| -// is knowable at all (no PLATFORM_EDGE_IPS, no IMDS answer), Resolve stays |
| -// nil so DNS stamping stands down: with nothing to compare a resolution |
| -// against, "pointing" vs "elsewhere" would be a guess, and unknowns are |
| -// never guessed. |
| +// the zero Config, on which Reconcile is inert by contract. |
| +// |
| +// The SECOND return is the parsed PLATFORM_EDGE_IPS list (with the public-IP |
| +// fallback applied). It is not a Config field: Config.EdgeIPs is now a func |
| +// answering the deployment's LIVE edge set, which needs a deployment store — |
| +// something this function must never touch if it is to stay pure and |
| +// getenv-injectable. domainsLoop folds the two together once its store is |
| +// open. A field only the agent's own wiring reads would be dead weight in |
| +// every other caller and every test fixture, hence a return value. |
| +// |
| +// Resolve is armed for every configured deployment now, no longer gated on |
| +// this box knowing an edge IP: a box with no PLATFORM_EDGE_IPS whose FOLD |
| +// supplies edges must still stamp. The stand-down when nothing is knowable |
| +// moved to where the set is actually known — domains.Reconcile's section 4, |
| +// per pass — because "no edge addresses" is now a per-pass fact, not a |
| +// boot-time one. |
| // |
| // The error return is ADVISORY, never fatal: it is non-nil only when |
| // CARLOS_RELEASE_PUBKEY was SET but would not parse, and it exists so the |
| @@ -1438,10 +1457,10 @@ func peekAcmeCache(args []string) string { |
| // error and a nil ReleasePub — the two are different operator problems and the |
| // one log line about them must not name the wrong one. The Config is fully |
| // usable either way; only the auto-purge is off. |
| -func domainsTickConfig(getenv func(string) string, publicIP string) (domains.Config, error) { |
| +func domainsTickConfig(getenv func(string) string, publicIP string) (domains.Config, []string, error) { |
| domain := strings.TrimSpace(getenv("PLATFORM_APPS_DOMAIN")) |
| if domain == "" { |
| - return domains.Config{}, nil |
| + return domains.Config{}, nil, nil |
| } |
| var ips []string |
| for _, ip := range strings.Split(getenv("PLATFORM_EDGE_IPS"), ",") { |
| @@ -1458,16 +1477,17 @@ func domainsTickConfig(getenv func(string) string, publicIP string) (domains.Con |
| } |
| cfg := domains.Config{ |
| AppsDomain: domain, |
| - EdgeIPs: ips, |
| // The same default the edge's -acme-cache flag carries |
| // (edge.RunWithActivator): $CARLOS_DATA_DIR/acme. domainsLoop |
| // overrides this when the flag was given explicitly. |
| AcmeDir: filepath.Join(dd, "acme"), |
| Now: time.Now, |
| + Resolve: resolveHost, |
| } |
| - if len(ips) > 0 { |
| - cfg.Resolve = resolveHost |
| - } |
| + // EdgeIPs is deliberately left nil here — a Config out of this function |
| + // alone stamps nothing. domainsLoop installs the fold once it has a |
| + // store; see the doc comment. |
| + |
| // The root release public key, same source and same parser as adoptLoop's. |
| // Absence is TOLERATED and leaves ReleasePub nil, which switches the |
| // auto-purge off: a box that cannot verify an app's production flag must |
| @@ -1479,11 +1499,11 @@ func domainsTickConfig(getenv func(string) string, publicIP string) (domains.Con |
| if raw := strings.TrimSpace(getenv("CARLOS_RELEASE_PUBKEY")); raw != "" { |
| pub, err := release.ParsePublicKey(raw, "CARLOS_RELEASE_PUBKEY") |
| if err != nil { |
| - return cfg, err |
| + return cfg, ips, err |
| } |
| cfg.ReleasePub = pub |
| } |
| - return cfg, nil |
| + return cfg, ips, nil |
| } |
| // provisionTickConfig builds one instances reconcile pass's Config from the |
| @@ -1625,7 +1645,7 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| log.Printf("agent: domains loop disabled: PLATFORM_APPS_DOMAIN is not set") |
| return |
| } |
| - cfg, keyErr := domainsTickConfig(os.Getenv, readPublicIP()) |
| + cfg, envIPs, keyErr := domainsTickConfig(os.Getenv, readPublicIP()) |
| if cfg.AppsDomain == "" { |
| log.Printf("agent: domains loop disabled: PLATFORM_APPS_DOMAIN is not set") |
| return |
| @@ -1638,7 +1658,21 @@ 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 — health stamps every tick; claims reconcile only while this box holds the housekeeper lease", cfg.AppsDomain, cfg.EdgeIPs) |
| + // The edge set is folded PER PASS, here rather than in |
| + // domainsTickConfig, because it needs the store this line above just |
| + // opened — and because a set read once at arm is the bug this replaces: |
| + // under latency steering a correctly-pointed customer domain resolves to |
| + // whichever region's edge is nearest, which a single box's boot-frozen |
| + // PLATFORM_EDGE_IPS reads as "elsewhere". envIPs stays in the union, so |
| + // a deployment whose bucket is unreadable degrades to exactly the old |
| + // behaviour and never below it. The bound matters: a hung store must |
| + // cost this pass 30s, not the loop forever. |
| + cfg.EdgeIPs = func() []string { |
| + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| + defer cancel() |
| + return edgesfile.EdgeIPs(ctx, store, envIPs, time.Now()) |
| + } |
| + log.Printf("agent: domains loop armed (5min + jitter), apps domain %s, configured edge IPs %v (the live set is folded from every box's edges file each pass) — health stamps every tick; claims reconcile only while this box holds the housekeeper lease", cfg.AppsDomain, envIPs) |
| // 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. |
| @@ -1661,8 +1695,11 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| // 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". |
| + // and it publishes no edges file, so it has no standing to judge the |
| + // deployment's DNS. (The old reason — that its EdgeIPs fall back to |
| + // its own address — no longer holds now that the set is folded from |
| + // every box's file; the exclusion stands on the publication rule |
| + // instead, which is the durable one.) |
| runHousekeeperDuties(housekeeperGate, duty) |
| if !fleetBox { |
| if err := domains.StampHealth(ctx, reg, cfg, nil); err != nil { |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index d59362e1d..11c140ffa 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -11,8 +11,10 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| ) |
| func edgesTestRegistry(t *testing.T) *registry.Registry { |
| @@ -768,3 +770,41 @@ func TestEdgesFilePublishesDelegatedWildcardParents(t *testing.T) { |
| t.Errorf("route-owned key = %+v; want platform AND wildcard, role home", h) |
| } |
| } |
| + |
| +// The whole EIP fold rests on ONE identity: the `box` name this loop |
| +// publishes under and the `label` the status snapshot is written under must |
| +// be the same string, or edgesfile.EdgeIPs' liveness join drops every edge |
| +// silently and the deployment's edge set degrades to env-only forever — a |
| +// failure with no error, no log line and no red test. |
| +// |
| +// They are the same today because both resolve through metricsBoxLabel over |
| +// the same machine id: edgesFileLoop calls it directly, and statusTick's |
| +// snapshot label is status.Label(Probes.MachineID), which is exactly what |
| +// metricsBoxLabel falls through to off a fleet box. (A fleet box never gets |
| +// here: edgesFileLoop returns early on shouldSkipPublisherElection.) This |
| +// test is the tripwire on that, end to end through the real fold. |
| +func TestEdgesFileBoxNameJoinsTheStatusSnapshotLabel(t *testing.T) { |
| + const machineID = "ip-10-0-0-7" |
| + notAFleetBox := func(string) string { return "" } |
| + |
| + box := metricsBoxLabel(notAFleetBox, machineID) |
| + snapshot := status.CollectBox(nil, status.Probes{MachineID: machineID}, time.Now()) |
| + if box != snapshot.Label { |
| + t.Fatalf("edges file box %q vs status snapshot label %q — the fold's liveness join needs one name", box, snapshot.Label) |
| + } |
| + |
| + ctx := context.Background() |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + seedRoute(t, reg, "app.example.com") |
| + var last []byte |
| + if err := edgesFilePass(ctx, s, reg, box, "203.0.113.7", "eu-west-1", &last); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := status.WriteBox(ctx, s, snapshot); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := edgesfile.EdgeIPs(ctx, s, nil, time.Now()) |
| + if len(got) != 1 || got[0] != "203.0.113.7" { |
| + t.Fatalf("fold = %v, want this box's own EIP — the two writers disagree about its name", got) |
| + } |
| +} |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 60194ca52..2be92e3c5 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -607,19 +607,30 @@ func TestWrapListeningProbeNullsUnitBackedSockets(t *testing.T) { |
| // TestDomainsTickConfig pins the env contract: PLATFORM_APPS_DOMAIN unset |
| // means a zero Config (Reconcile is inert on it — the feature does not |
| // exist); set means every seam is populated, with PLATFORM_EDGE_IPS parsed |
| -// comma-separated and the box's public IP as the fallback. When NO edge IP |
| -// is known at all, Resolve stays nil: with nothing to compare against, |
| -// "pointing" vs "elsewhere" would be a guess, and unknowns are never guessed. |
| +// comma-separated and the box's public IP as the fallback. |
| +// |
| +// That parsed list is a RETURN VALUE, not a Config field, and this test |
| +// asserts it there. Config.EdgeIPs is now a func answering the deployment's |
| +// LIVE edge set — every box's published edges file folded against its status |
| +// snapshot — which needs a deployment store, and this builder must stay pure |
| +// and getenv-injectable. domainsLoop unions the two once its store is open, |
| +// so what is pinned here is exactly the ENV half of that union. |
| +// |
| +// It follows that this builder never populates EdgeIPs at all, and that |
| +// Resolve no longer waits on this box knowing an edge address: a box with no |
| +// PLATFORM_EDGE_IPS whose fold supplies edges must still stamp. The |
| +// stand-down when nothing is knowable moved to domains.Reconcile's section 4, |
| +// where the set is actually known — see TestReconcileStandsDownWithNoEdgeIPs. |
| func TestDomainsTickConfig(t *testing.T) { |
| env := func(m map[string]string) func(string) string { |
| return func(k string) string { return m[k] } |
| } |
| - if got, err := domainsTickConfig(env(map[string]string{}), "9.9.9.9"); !reflect.DeepEqual(got, domains.Config{}) || err != nil { |
| - t.Errorf("unset PLATFORM_APPS_DOMAIN: config = %+v, err = %v, want zero, nil", got, err) |
| + if got, envIPs, err := domainsTickConfig(env(map[string]string{}), "9.9.9.9"); !reflect.DeepEqual(got, domains.Config{}) || envIPs != nil || err != nil { |
| + t.Errorf("unset PLATFORM_APPS_DOMAIN: config = %+v, envIPs = %v, err = %v, want zero, nil, nil", got, envIPs, err) |
| } |
| - got, err := domainsTickConfig(env(map[string]string{ |
| + got, envIPs, err := domainsTickConfig(env(map[string]string{ |
| "PLATFORM_APPS_DOMAIN": "oncarlos.test", |
| "PLATFORM_EDGE_IPS": "203.0.113.7, 198.51.100.9", |
| "CARLOS_DATA_DIR": "/custom", |
| @@ -630,8 +641,11 @@ func TestDomainsTickConfig(t *testing.T) { |
| if got.AppsDomain != "oncarlos.test" { |
| t.Errorf("AppsDomain = %q", got.AppsDomain) |
| } |
| - if !reflect.DeepEqual(got.EdgeIPs, []string{"203.0.113.7", "198.51.100.9"}) { |
| - t.Errorf("EdgeIPs = %v, want the parsed PLATFORM_EDGE_IPS (public IP must not override an explicit list)", got.EdgeIPs) |
| + if !reflect.DeepEqual(envIPs, []string{"203.0.113.7", "198.51.100.9"}) { |
| + t.Errorf("envIPs = %v, want the parsed PLATFORM_EDGE_IPS (public IP must not override an explicit list)", envIPs) |
| + } |
| + if got.EdgeIPs != nil { |
| + t.Error("EdgeIPs must be left nil here: only domainsLoop, which has the store, can answer the live set") |
| } |
| if got.AcmeDir != filepath.Join("/custom", "acme") { |
| t.Errorf("AcmeDir = %q, want the edge's cache dir under CARLOS_DATA_DIR", got.AcmeDir) |
| @@ -641,19 +655,23 @@ func TestDomainsTickConfig(t *testing.T) { |
| } |
| // No explicit edge IPs: the box's own public IP (IMDS) is the default. |
| - got, _ = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "9.9.9.9") |
| - if !reflect.DeepEqual(got.EdgeIPs, []string{"9.9.9.9"}) { |
| - t.Errorf("EdgeIPs = %v, want the public-IP fallback", got.EdgeIPs) |
| + got, envIPs, _ = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "9.9.9.9") |
| + if !reflect.DeepEqual(envIPs, []string{"9.9.9.9"}) { |
| + t.Errorf("envIPs = %v, want the public-IP fallback", envIPs) |
| } |
| if got.AcmeDir != filepath.Join("/data", "acme") { |
| t.Errorf("AcmeDir = %q, want the /data default", got.AcmeDir) |
| } |
| - // No edge IPs knowable at all: DNS stamping stands down (Resolve nil), |
| - // but minting still runs — the domain still works, only health is unknown. |
| - got, _ = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "") |
| - if got.EdgeIPs != nil || got.Resolve != nil { |
| - t.Errorf("no known edge IPs: EdgeIPs = %v Resolve nil? %v — stamping must stand down, not guess", got.EdgeIPs, got.Resolve == nil) |
| + // No edge IP knowable from the ENVIRONMENT at all. The env half is empty |
| + // — but Resolve is still armed, because the fold may well know edges this |
| + // box's variables never named. Minting runs regardless. |
| + got, envIPs, _ = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "") |
| + if envIPs != nil { |
| + t.Errorf("envIPs = %v, want nothing: neither PLATFORM_EDGE_IPS nor IMDS answered", envIPs) |
| + } |
| + if got.Resolve == nil { |
| + t.Error("Resolve must stay armed with no env edge IPs — the fold, not this box's variables, decides whether a pass can stamp") |
| } |
| if got.AppsDomain != "oncarlos.test" { |
| t.Error("minting must stay configured even without edge IPs") |
| @@ -674,7 +692,7 @@ func TestDomainsTickConfigReleasePubKey(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - got, err := domainsTickConfig(env(map[string]string{ |
| + got, _, err := domainsTickConfig(env(map[string]string{ |
| "PLATFORM_APPS_DOMAIN": "oncarlos.test", |
| "CARLOS_RELEASE_PUBKEY": pubB64, |
| }), "9.9.9.9") |
| @@ -695,7 +713,7 @@ func TestDomainsTickConfigReleasePubKey(t *testing.T) { |
| // (nil, error). domainsLoop logs the error verbatim, which is the one line |
| // an operator reads when purging silently is not happening. |
| for _, key := range []string{"", " "} { |
| - got, err = domainsTickConfig(env(map[string]string{ |
| + got, _, err = domainsTickConfig(env(map[string]string{ |
| "PLATFORM_APPS_DOMAIN": "oncarlos.test", |
| "CARLOS_RELEASE_PUBKEY": key, |
| }), "9.9.9.9") |
| @@ -704,7 +722,7 @@ func TestDomainsTickConfigReleasePubKey(t *testing.T) { |
| } |
| } |
| for _, key := range []string{"not-base64!!", base64.StdEncoding.EncodeToString([]byte("short"))} { |
| - got, err = domainsTickConfig(env(map[string]string{ |
| + got, _, err = domainsTickConfig(env(map[string]string{ |
| "PLATFORM_APPS_DOMAIN": "oncarlos.test", |
| "CARLOS_RELEASE_PUBKEY": key, |
| }), "9.9.9.9") |