Merge pull request #301 from carlosframework/wildcard-parents-deployment
edge: wildcard parents are derived from the deployment's hosts, not the box's (#300)
7 files changed,
+462
−14
docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md+10 −1internal/edge/edge.go+29 −0internal/edge/homemap.go+93 −1internal/edge/wildcard.go+21 −8internal/edge/wildcard_parents_test.go+252 −0internal/host/edgesfile.go+8 −1internal/host/edgesfile_test.go+49 −3
diff --git a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md| index 530fcf771..8bf68d77c 100644 |
| --- a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md |
| +++ b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md |
| @@ -396,7 +396,16 @@ timestamp writes nothing. Edge writes do NOT bump `control/serial` — |
| the serial stays console-written per the writer map, matching Part 2's |
| certs ruling; edges mirror the `control/edges/` prefix on their own |
| 30-second poll, the same cadence the certs mirror uses. Every edge |
| -folds these files into a deployment-wide host→home map. The file |
| +folds these files into a deployment-wide host→home map. **Amendment |
| +(2026-08-26, #300):** each host object also carries `platform: bool` |
| +(additive, always written) — the publisher's class mark for |
| +platform-minted rows (registry Platform or Provisioned). Every edge |
| +derives its wildcard-parent set from the DEPLOYMENT's marked names |
| +(its own routes ∪ the home map's marked hosts), not the box's own |
| +routes, so a region edge serves the account wildcard of an app it only |
| +backhauls to. Fail closed: a file without the key (pre-mark publisher) |
| +contributes no parents until its box republishes, so home boxes roll |
| +before region edges. The file |
| doubles as the DR runbook's explicit statement of what a dead box was |
| serving. **Keys are registry row hosts verbatim, including `*.parent` |
| catch-all rows** — this writer performs no wildcard expansion, so a |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 3e4e35d7a..d50ce448c 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -728,6 +728,32 @@ func (e *Edge) currentHosts() []string { |
| return out |
| } |
| +// deploymentHosts is currentHosts plus every exact platform-class name the |
| +// home map says another box homes — the input the wildcard manager's parent |
| +// set is built from (#300). Both halves apply the same class bound (the |
| +// publisher marks what currentHosts would admit); a box with no home map |
| +// (no deployment store) is its own deployment and gets currentHosts alone. |
| +// Not to be confused with deploymentHost (singular): that is the cert |
| +// gates' admission predicate and admits more (aliases, the tunnel host). |
| +func (e *Edge) deploymentHosts() []string { |
| + own := e.currentHosts() |
| + hm := e.homes.Load() |
| + if hm == nil { |
| + return own |
| + } |
| + seen := make(map[string]bool, len(own)) |
| + for _, h := range own { |
| + seen[h] = true |
| + } |
| + for _, h := range hm.Hosts() { |
| + if !seen[h] { |
| + seen[h] = true |
| + own = append(own, h) |
| + } |
| + } |
| + return own |
| +} |
| + |
| // UpstreamStates is the probe table as the rest of the process may see it: |
| // route host -> each upstream's current state, in the route's addr order, |
| // for probed (multi-upstream) routes only. A route with no health config |
| @@ -2381,6 +2407,9 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun |
| getCert = wrapGetCertificate(w, getCert) |
| e.OnReload(w.refresh) |
| attachWildcardToMirror(mirror, w) |
| + // A remote box's edges file landing (or leaving) can add or drop a |
| + // parent (#300): re-derive on the home map's poll, not the 12h tick. |
| + e.homes.Load().setHostsWake(w.refresh) |
| w.refresh() |
| go w.run() |
| log.Printf("edge: wildcard certs on (static %v, apps domain %q, delegation zone %q)", |
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go| index 5b115d9bd..d8931fcf2 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -79,6 +79,7 @@ import ( |
| "net/http/httputil" |
| "os" |
| "slices" |
| + "sort" |
| "strings" |
| "sync" |
| "time" |
| @@ -132,12 +133,19 @@ type edgesFileView struct { |
| // what makes the role field addable without a wire break — see edgeRoleHome. |
| type edgesHostView struct { |
| Role string `json:"role"` |
| + // Platform is the publisher's class mark (internal/host edgesfile.go): |
| + // a platform-minted row. Absent on files written before the mark |
| + // existed, which reads as "not platform" — fail closed for the one |
| + // consumer that cares (Hosts, the wildcard parent set). |
| + Platform bool `json:"platform,omitempty"` |
| } |
| // homeEntry is one folded answer: where to dial, and which box that is. |
| type homeEntry struct { |
| eip string |
| box string |
| + // platform carries the publisher's class mark through the fold. |
| + platform bool |
| } |
| // homeSnapshot is one poll's result, swapped in whole and immutable once |
| @@ -169,6 +177,8 @@ type homeMap struct { |
| mu sync.RWMutex |
| snap *homeSnapshot |
| + // hostsWake fires after a Refresh that changed the NAME set (setHostsWake). |
| + hostsWake func() |
| // partialPolls counts polls that could not read (or parse) every object, |
| // for the first-and-every-tenth log cadence. |
| partialPolls int |
| @@ -292,10 +302,92 @@ func (m *homeMap) refreshHosts(ctx context.Context) error { |
| snap := &homeSnapshot{files: next, hosts: foldHomes(next, m.self)} |
| m.mu.Lock() |
| m.snap = snap |
| + wake := m.hostsWake |
| m.mu.Unlock() |
| + if wake != nil && !samePlatformSet(prev.hosts, snap.hosts) { |
| + // The deployment's platform name set moved: a wildcard parent may |
| + // have appeared for an account homed elsewhere (#300). Off this |
| + // goroutine — the wake re-derives through Hosts() (its own locks) |
| + // and may touch the registry, and refreshKeys should not wait on |
| + // that. |
| + go wake() |
| + } |
| return nil |
| } |
| +// samePlatformSet reports whether two folds name the same PLATFORM hosts — |
| +// the only names that can move a wildcard parent (homes may differ; an |
| +// unclassed name may come and go). |
| +func samePlatformSet(a, b map[string]homeEntry) bool { |
| + na, nb := 0, 0 |
| + for h, e := range a { |
| + if !parentInput(h, e) { |
| + continue |
| + } |
| + na++ |
| + if eb, ok := b[h]; !ok || !parentInput(h, eb) { |
| + return false |
| + } |
| + } |
| + for h, e := range b { |
| + if parentInput(h, e) { |
| + nb++ |
| + } |
| + } |
| + return na == nb |
| +} |
| + |
| +// parentInput is the one filter Hosts and samePlatformSet share: a marked, |
| +// exact (non catch-all) name. A catch-all coming or going cannot move a |
| +// parent, so it must not wake the manager either. |
| +func parentInput(host string, e homeEntry) bool { |
| + return e.platform && !strings.HasPrefix(host, "*.") |
| +} |
| + |
| +// Hosts is every exact PLATFORM-class name the deployment's OTHER boxes |
| +// home. Sorted, nil-safe. The wildcard manager unions these with this box's |
| +// own routes so its parent set is the deployment's, not the box's (#300): |
| +// a region edge that adopts only everywhere-class routes would otherwise |
| +// never derive the account parent of an exec app homed elsewhere, and so |
| +// never load the wildcard PEM the mirror had already written to its disk. |
| +// |
| +// Two bounds, both fail-closed: the publisher's class mark (a row that is |
| +// neither Platform nor Provisioned — an operator's stray `carlos add` |
| +// under the apps domain — derives nothing here, exactly as currentHosts |
| +// refuses it locally; a file from before the mark existed contributes |
| +// nothing until its box republishes), and parentsFor's two-labels-under- |
| +// the-apps-domain rule on top. Catch-all "*." keys are excluded: a |
| +// remote catch-all is a claim's row, never an account parent. An |
| +// unlabelled box (self == "") reports nothing, as Home does — its own |
| +// file would otherwise fold in as "another box". |
| +func (m *homeMap) Hosts() []string { |
| + if m == nil || m.self == "" { |
| + return nil |
| + } |
| + snap := m.snapshot() |
| + out := make([]string, 0, len(snap.hosts)) |
| + for h, e := range snap.hosts { |
| + if !parentInput(h, e) { |
| + continue |
| + } |
| + out = append(out, h) |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +// setHostsWake installs the callback a changed host set fires (Refresh). |
| +// Under the map's own lock: run() is already polling when the wildcard |
| +// manager is wired in after it. |
| +func (m *homeMap) setHostsWake(fn func()) { |
| + if m == nil { |
| + return |
| + } |
| + m.mu.Lock() |
| + m.hostsWake = fn |
| + m.mu.Unlock() |
| +} |
| + |
| // parseEdgesFile decodes one box's file. A parse failure gets the same |
| // treatment an unreadable object gets — carry the last good parse forward — |
| // because a half-written body is no more usable than a body that never |
| @@ -370,7 +462,7 @@ func foldHomes(files map[string]edgesFileView, self string) map[string]homeEntry |
| if _, dup := hosts[host]; dup { |
| continue // first box in sorted order wins, everywhere |
| } |
| - hosts[host] = homeEntry{eip: f.EIP, box: f.Box} |
| + hosts[host] = homeEntry{eip: f.EIP, box: f.Box, platform: h.Platform} |
| } |
| } |
| return hosts |
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go| index 22bc0f261..681c3bfa1 100644 |
| --- a/internal/edge/wildcard.go |
| +++ b/internal/edge/wildcard.go |
| @@ -68,7 +68,7 @@ type wildcardManager struct { |
| staticDomains []string |
| appsDomain string |
| - hosts func() []string // live route hosts (Edge.currentHosts) |
| + hosts func() []string // the deployment's live hosts (Edge.deploymentHosts) |
| // delegated lists customer domains approved for delegated-challenge |
| // wildcard issuance (spec §4: claim-derived; nil = none). Their |
| @@ -83,9 +83,11 @@ type wildcardManager struct { |
| // challengeFQDN under mu (issuance runs on run()'s goroutine). |
| delegatedSet map[string]bool |
| - mu sync.Mutex |
| - parents []string // last computed parent set (sorted) |
| - certs map[string]*tls.Certificate |
| + // refreshMu serializes refresh() (see there); mu guards the fields. |
| + refreshMu sync.Mutex |
| + mu sync.Mutex |
| + parents []string // last computed parent set (sorted) |
| + certs map[string]*tls.Certificate |
| // nextAttempt holds a per-parent backoff floor: run() skips a parent |
| // until this time has passed. Set on obtain failure, cleared on success — |
| @@ -177,6 +179,13 @@ func parentsFor(staticAndDelegated []string, appsDomain string, hosts []string) |
| // parent's backoff: run() still skips any parent whose nextAttempt hasn't |
| // passed, so a wake only advances parents that aren't already backing off. |
| func (w *wildcardManager) refresh() { |
| + // One derivation at a time: hosts() and delegated() are read outside |
| + // w.mu, and three callers (the reload hook, the cert mirror's wake, the |
| + // home map's wake — two of them 30s pollers) could otherwise interleave |
| + // so that an older read lands last and drops a parent until the next |
| + // wake. Serialized, the last writer is the last reader. |
| + w.refreshMu.Lock() |
| + defer w.refreshMu.Unlock() |
| var delegated []string |
| if w.delegated != nil { |
| static := make(map[string]bool, len(w.staticDomains)) |
| @@ -774,9 +783,10 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager { |
| } |
| // Delegated source: the claim-derivation pass (Task 3, internal/domains) |
| // writes wildcard_domains; refresh() reads it fresh on every call, same |
| - // as e.currentHosts above. A read error returns the LAST successful |
| - // list, not nil/empty: refresh() only runs on startup and on OnReload |
| - // (run()'s 12h ticker never re-invokes the delegated source itself), so |
| + // as e.deploymentHosts above. A read error returns the LAST successful |
| + // list, not nil/empty: refresh() runs on startup, on OnReload, and on the |
| + // cert mirror's and home map's wakes (run()'s 12h ticker never |
| + // re-invokes the delegated source itself), so |
| // on a quiet box a transient error would otherwise empty the delegated |
| // set until some unrelated registry write next moves the fingerprint — |
| // unbounded, and it would also stop parentsFor from including a parent |
| @@ -805,7 +815,10 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager { |
| mu.Unlock() |
| return out |
| } |
| - w := newWildcardManager(static, appsDomain, e.currentHosts, delegated, zone, cache, dns) |
| + // Hosts are the DEPLOYMENT's (this box's routes plus every name another |
| + // box homes, #300), so a region edge derives the account parents of apps |
| + // it only ever backhauls to — and loads their mirrored wildcards. |
| + w := newWildcardManager(static, appsDomain, e.deploymentHosts, delegated, zone, cache, dns) |
| // DNS-01 orders are gated on the same election per-host HTTP-01 orders |
| // are (certsync.go): one issuer per deployment, whichever protocol the |
| // name needs. The cache handle is already the publishing one, so the |
diff --git a/internal/edge/wildcard_parents_test.go b/internal/edge/wildcard_parents_test.go| new file mode 100644 |
| index 000000000..a97052123 |
| --- /dev/null |
| +++ b/internal/edge/wildcard_parents_test.go |
| @@ -0,0 +1,252 @@ |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "path/filepath" |
| + "reflect" |
| + "sort" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "golang.org/x/crypto/acme/autocert" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// waitWoke polls an atomic wake counter — the wake is fired off the |
| +// refresh goroutine, so a test observes it, never assumes it. |
| +func waitWoke(t *testing.T, woke *atomic.Int32, want int32) { |
| + t.Helper() |
| + deadline := time.Now().Add(2 * time.Second) |
| + for woke.Load() != want { |
| + if time.Now().After(deadline) { |
| + t.Fatalf("woke = %d; want %d", woke.Load(), want) |
| + } |
| + time.Sleep(time.Millisecond) |
| + } |
| +} |
| + |
| +// #300: the home map exposes the deployment's platform-class names, and a |
| +// changed platform name set wakes whoever asked (the wildcard manager). |
| +func TestHomeMapHostsAndWakeOnChange(t *testing.T) { |
| + s := meshTestStore(t) |
| + putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"own.acct.apps.test":{"role":"home","platform":true}}}`) |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"cribado.bad.apps.test":{"role":"home","platform":true},"*.catch.test":{"role":"home","platform":true},"stray.foo.apps.test":{"role":"home"}}}`) |
| + m := newHomeMap(s, "annie") |
| + var woke atomic.Int32 |
| + m.setHostsWake(func() { woke.Add(1) }) |
| + refreshHomeMap(t, m) |
| + if got, want := m.Hosts(), []string{"cribado.bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("Hosts = %v; want %v (own box, catch-all and unclassed rows excluded)", got, want) |
| + } |
| + waitWoke(t, &woke, 1) |
| + refreshHomeMap(t, m) // unchanged: no wake |
| + time.Sleep(20 * time.Millisecond) |
| + if woke.Load() != 1 { |
| + t.Fatalf("an unchanged refresh woke the manager (%d)", woke.Load()) |
| + } |
| + // Home moved, and an UNCLASSED name came and went: still no wake. |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"3.3.3.3","hosts":{"cribado.bad.apps.test":{"role":"home","platform":true},"*.catch.test":{"role":"home","platform":true},"other.foo.apps.test":{"role":"home"}}}`) |
| + refreshHomeMap(t, m) |
| + time.Sleep(20 * time.Millisecond) |
| + if woke.Load() != 1 { |
| + t.Fatalf("a moved home / unclassed churn woke the manager (%d)", woke.Load()) |
| + } |
| + // Same count, different platform name (a swap in one poll): wakes. |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"3.3.3.3","hosts":{"other.bad.apps.test":{"role":"home","platform":true},"*.catch.test":{"role":"home","platform":true}}}`) |
| + refreshHomeMap(t, m) |
| + waitWoke(t, &woke, 2) |
| + putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"4.4.4.4","hosts":{"new.bae.apps.test":{"role":"home","platform":true}}}`) |
| + refreshHomeMap(t, m) |
| + waitWoke(t, &woke, 3) |
| + if got, want := m.Hosts(), []string{"new.bae.apps.test", "other.bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("Hosts = %v; want %v", got, want) |
| + } |
| + |
| + // A name LOSING its mark (a home box rolled back below the mark) is a |
| + // change: the parent must leave, and the manager must hear about it. |
| + putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"4.4.4.4","hosts":{"new.bae.apps.test":{"role":"home"}}}`) |
| + refreshHomeMap(t, m) |
| + waitWoke(t, &woke, 4) |
| + if got, want := m.Hosts(), []string{"other.bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("Hosts after unmark = %v; want %v", got, want) |
| + } |
| + // A marked catch-all coming and going moves nothing and wakes nobody. |
| + putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"4.4.4.4","hosts":{"new.bae.apps.test":{"role":"home"},"*.claimed.apps.test":{"role":"home","platform":true}}}`) |
| + refreshHomeMap(t, m) |
| + time.Sleep(20 * time.Millisecond) |
| + if woke.Load() != 4 { |
| + t.Fatalf("a catch-all woke the manager (%d)", woke.Load()) |
| + } |
| + if got, want := m.Hosts(), []string{"other.bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("Hosts after catch-all = %v; want %v", got, want) |
| + } |
| + |
| + var nilMap *homeMap |
| + if nilMap.Hosts() != nil { |
| + t.Fatal("nil map must report no hosts") |
| + } |
| + nilMap.setHostsWake(func() {}) // no panic |
| + // An unlabelled box (hand-run `carlos edge`) would fold its OWN file in |
| + // as another box's: it reports nothing, as Home does. |
| + unlabelled := newHomeMap(s, "") |
| + refreshHomeMap(t, unlabelled) |
| + if got := unlabelled.Hosts(); got != nil { |
| + t.Fatalf("unlabelled box reported %v", got) |
| + } |
| +} |
| + |
| +// A file written before the class mark existed carries no flag: nothing |
| +// in it derives a parent until its box republishes. Fail closed. |
| +func TestHomeMapHostsIgnoresPreMarkFiles(t *testing.T) { |
| + s := meshTestStore(t) |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"cribado.bad.apps.test":{"role":"home"}}}`) |
| + m := newHomeMap(s, "annie") |
| + refreshHomeMap(t, m) |
| + if got := m.Hosts(); len(got) != 0 { |
| + t.Fatalf("pre-mark file contributed %v", got) |
| + } |
| + if _, _, ok := m.Home("cribado.bad.apps.test"); !ok { |
| + t.Fatal("the class mark must not affect backhaul") |
| + } |
| +} |
| + |
| +// platformHomedOn is homedOn with the publisher's class mark set — the |
| +// shape a real edges file has for platform-minted rows (#300). |
| +func platformHomedOn(t *testing.T, m *homeMap, box, eip string, hosts ...string) { |
| + t.Helper() |
| + entries := make([]string, 0, len(hosts)) |
| + for _, h := range hosts { |
| + entries = append(entries, fmt.Sprintf("%q:{\"role\":\"home\",\"platform\":true}", h)) |
| + } |
| + putJSON(t, m.s, "control/edges/"+box+".json", fmt.Sprintf( |
| + `{"v":1,"box":%q,"eip":%q,"hosts":{%s}}`, box, eip, strings.Join(entries, ","))) |
| + refreshHomeMap(t, m) |
| +} |
| + |
| +// deploymentHosts is this box's routes plus the home map's names, deduped. |
| +func TestDeploymentHostsUnionsTheHomeMap(t *testing.T) { |
| + e, _, _ := remoteHomedEdge(t, []string{"own.acct.apps.test"}) |
| + platformHomedOn(t, e.homes.Load(), "betty", "2.2.2.2", "cribado.bad.apps.test", "own.acct.apps.test") |
| + got := e.deploymentHosts() |
| + sort.Strings(got) |
| + if want := []string{"cribado.bad.apps.test", "own.acct.apps.test"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("deploymentHosts = %v; want %v", got, want) |
| + } |
| + // No deployment store, no home map: a single-box deployment is its own. |
| + lone := testIssuerEdge("own.acct.apps.test") |
| + if lone.homes.Load() != nil { |
| + t.Fatal("test edge unexpectedly has a home map") |
| + } |
| + if got := lone.deploymentHosts(); !reflect.DeepEqual(got, lone.currentHosts()) { |
| + t.Fatalf("no home map: deploymentHosts %v != currentHosts %v", got, lone.currentHosts()) |
| + } |
| +} |
| + |
| +// The manager built from env derives the parent of an app it only ever |
| +// backhauls to — the region-edge shape that failed on tito stage 6. |
| +func TestWildcardParentsIncludeRemoteHomedAccounts(t *testing.T) { |
| + e, _, _ := remoteHomedEdge(t, []string{"static.bdj.apps.test"}) |
| + platformHomedOn(t, e.homes.Load(), "betty", "2.2.2.2", "cribado.bad.apps.test", "foreign.report") |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + e.reg = reg |
| + t.Setenv("CARLOS_WILDCARD_DOMAINS", "") |
| + t.Setenv("PLATFORM_APPS_DOMAIN", "apps.test") |
| + t.Setenv("CARLOS_DNS_PROVIDER", "route53") |
| + t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "") |
| + w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir())) |
| + if w == nil { |
| + t.Fatal("want a manager") |
| + } |
| + w.refresh() |
| + // testIssuerEdge's own rows carry no trust flag, so currentHosts (#290, |
| + // pinned in wildcard_test.go) contributes nothing here: what is left is |
| + // exactly the remote account — and never the foreign domain. |
| + if got, want := w.currentParents(), []string{"bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("parents = %v; want %v (remote account included, foreign domain not)", got, want) |
| + } |
| +} |
| + |
| +// The production wiring, end to end: a remote box's file landing grows the |
| +// manager's parent set on the home map's poll, and its leaving shrinks it. |
| +func TestHomeMapWakeReachesTheWildcardManager(t *testing.T) { |
| + e, _, base := remoteHomedEdge(t, nil) |
| + platformHomedOn(t, e.homes.Load(), "betty", "2.2.2.2", "seed.bdj.apps.test") |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + e.reg = reg |
| + t.Setenv("CARLOS_WILDCARD_DOMAINS", "") |
| + t.Setenv("PLATFORM_APPS_DOMAIN", "apps.test") |
| + t.Setenv("CARLOS_DNS_PROVIDER", "route53") |
| + t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "") |
| + w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir())) |
| + hm := e.homes.Load() |
| + hm.setHostsWake(w.refresh) |
| + w.refresh() |
| + if got := w.currentParents(); !reflect.DeepEqual(got, []string{"bdj.apps.test"}) { |
| + t.Fatalf("seed parents = %v", got) |
| + } |
| + putJSON(t, base, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"4.4.4.4","hosts":{"cribado.bad.apps.test":{"role":"home","platform":true}}}`) |
| + refreshHomeMap(t, hm) |
| + waitParents(t, w, []string{"bad.apps.test", "bdj.apps.test"}) |
| + if err := base.Delete(context.Background(), "control/edges/carol.json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + refreshHomeMap(t, hm) |
| + waitParents(t, w, []string{"bdj.apps.test"}) |
| +} |
| + |
| +func waitParents(t *testing.T, w *wildcardManager, want []string) { |
| + t.Helper() |
| + deadline := time.Now().Add(2 * time.Second) |
| + for { |
| + if got := w.currentParents(); reflect.DeepEqual(got, want) { |
| + return |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatalf("parents = %v; want %v", w.currentParents(), want) |
| + } |
| + time.Sleep(time.Millisecond) |
| + } |
| +} |
| + |
| +// refresh() is serialized: whatever interleaving the three callers produce, |
| +// the parent set that lands last is the one derived from the latest hosts. |
| +func TestWildcardRefreshIsSerialized(t *testing.T) { |
| + var gen atomic.Int32 |
| + hosts := func() []string { |
| + n := gen.Load() |
| + // A slow read: the interleaving window the mutex closes. |
| + time.Sleep(200 * time.Microsecond) |
| + return []string{fmt.Sprintf("app.acct%d.apps.test", n)} |
| + } |
| + w := newWildcardManager(nil, "apps.test", hosts, func() []string { return nil }, "", autocert.DirCache(t.TempDir()), nil) |
| + var wg sync.WaitGroup |
| + for i := 0; i < 8; i++ { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + for j := 0; j < 25; j++ { |
| + gen.Add(1) |
| + w.refresh() |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + final := gen.Load() |
| + w.refresh() |
| + if got := w.currentParents(); !reflect.DeepEqual(got, []string{fmt.Sprintf("acct%d.apps.test", final)}) { |
| + t.Fatalf("parents = %v after generation %d", got, final) |
| + } |
| +} |
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index 8b18bb192..e40f4bea8 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -36,6 +36,13 @@ func edgesFileKey(box string) string { return edgesFilePrefix + box + ".json" } |
| // grow into without a wire-shape break. |
| type edgeHost struct { |
| Role string `json:"role"` |
| + // Platform marks a row the platform minted (registry Platform or |
| + // Provisioned) — the class edge.currentHosts admits to wildcard-parent |
| + // derivation. Additive; always emitted (never omitempty), so a file with |
| + // no "platform" key at all is definitely a pre-mark publisher, not a box |
| + // homing nothing platform-class. A reader seeing no flag treats the host |
| + // as unclassed and derives no parent from it (#300). |
| + Platform bool `json:"platform"` |
| } |
| // edgesFile is the wire shape at edgesFileKey(box), schema v1 (spec Part 3): |
| @@ -116,7 +123,7 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, |
| // answers must apply edge.go's lookupUpstream one-label wildcard |
| // rule itself: an exact match wins, and on a miss "*.<parent>" |
| // (the leading dot of the first label only) is tried next. |
| - hosts[r.Host] = edgeHost{Role: "home"} |
| + hosts[r.Host] = edgeHost{Role: "home", Platform: r.Platform || r.Provisioned} |
| } |
| if eip == "" && *last != nil { |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index fbffb76fe..79966559a 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "encoding/json" |
| "path/filepath" |
| + "strings" |
| "testing" |
| "time" |
| @@ -263,7 +264,7 @@ func TestEdgesFileRawWireBodyMatchesSchema(t *testing.T) { |
| } |
| want := `{"v":1,"box":"annie-welding","eip":"99.81.104.219","generation":"` + |
| - probe.Generation + `","hosts":{"app.example.com":{"role":"home"}}}` |
| + probe.Generation + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| if string(got) != want { |
| t.Fatalf("raw wire body =\n%s\nwant\n%s", got, want) |
| } |
| @@ -305,7 +306,7 @@ func TestEdgesFileRawWireBodyRegionField(t *testing.T) { |
| } |
| gen := genOf(t, got) |
| want := `{"v":1,"box":"annie-welding","eip":"99.81.104.219","generation":"` + |
| - gen + `","hosts":{"app.example.com":{"role":"home"}}}` |
| + gen + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| if string(got) != want { |
| t.Fatalf("raw wire body (region unset) =\n%s\nwant\n%s", got, want) |
| } |
| @@ -324,7 +325,7 @@ func TestEdgesFileRawWireBodyRegionField(t *testing.T) { |
| } |
| gen := genOf(t, got) |
| want := `{"v":1,"box":"annie-welding","eip":"99.81.104.219","region":"eu-west-1","generation":"` + |
| - gen + `","hosts":{"app.example.com":{"role":"home"}}}` |
| + gen + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| if string(got) != want { |
| t.Fatalf("raw wire body (region set) =\n%s\nwant\n%s", got, want) |
| } |
| @@ -566,3 +567,48 @@ func TestEdgesFileSeedPreRegionBucketWritesOnceWhenRegionArrives(t *testing.T) { |
| t.Fatalf("puts after unchanged second pass = %d, want still 1", counting.puts) |
| } |
| } |
| + |
| +// The class mark (#300): platform-minted rows carry platform:true; a stray |
| +// operator row does not, so no remote edge derives a wildcard parent from it. |
| +func TestEdgesFileMarksPlatformRows(t *testing.T) { |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + for _, r := range []registry.Route{ |
| + {Host: "app.acct.apps.test", Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", Platform: true}, |
| + {Host: "prov.acct.apps.test", Kind: "service", Addr: "10.0.0.1:3001", Status: "ready", Provisioned: true}, |
| + {Host: "stray.acct.apps.test", Kind: "service", Addr: "10.0.0.1:3002", Status: "ready"}, |
| + {Host: "pending.acct.apps.test", Kind: "service", Addr: "10.0.0.1:3003", Status: "pending", Platform: true}, |
| + } { |
| + if err := reg.Upsert(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + var lastBody []byte |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &lastBody); err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var f struct { |
| + Hosts map[string]struct { |
| + Platform bool `json:"platform"` |
| + } `json:"hosts"` |
| + } |
| + if err := json.Unmarshal(body, &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + for host, want := range map[string]bool{"app.acct.apps.test": true, "prov.acct.apps.test": true, "stray.acct.apps.test": false} { |
| + if got := f.Hosts[host].Platform; got != want { |
| + t.Errorf("%s platform = %v; want %v", host, got, want) |
| + } |
| + } |
| + if _, ok := f.Hosts["pending.acct.apps.test"]; ok { |
| + t.Error("a non-ready row is published regardless of class") |
| + } |
| + // The mark is always written, so a keyless host object can only mean a |
| + // pre-mark publisher. |
| + if !strings.Contains(string(body), `"stray.acct.apps.test":{"role":"home","platform":false}`) { |
| + t.Errorf("mark not emitted for an unclassed row: %s", body) |
| + } |
| +} |