carlos / platform Public

Merge pull request #301 from carlosframework/wildcard-parents-deployment

edge: wildcard parents are derived from the deployment's hosts, not the box's (#300)
Paul Campbell pushed by paul@keymail.dev da9a7eeb2e51b2944d2a4826f5930c66d01e56fd parent cbb6560parent 96fbeef
7 files changed, +462 −14
  • docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md +10 −1
  • internal/edge/edge.go +29 −0
  • internal/edge/homemap.go +93 −1
  • internal/edge/wildcard.go +21 −8
  • internal/edge/wildcard_parents_test.go +252 −0
  • internal/host/edgesfile.go +8 −1
  • internal/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)
+ }
+}