edge: wildcard parents — class mark on the wire, serialized refresh, async wake (#300 review)
Review round 1: the remote half skipped the Platform/Provisioned class filter currentHosts applies (the wire carried no class), a hand-run unlabelled edge folded its own file in as another box's, and refresh() had a third unserialized caller so an older hosts() read could land last and drop a parent. The publisher now marks platform-minted rows (edgeHost.Platform, additive, omitempty); Hosts() admits only marked names and reports nothing for an unlabelled box — files from before the mark contribute nothing until their box republishes (fail closed). refresh() is serialized under its own mutex and the home-map wake runs off the poll goroutine, so refreshKeys never waits on a registry read. The wake predicate compares platform names only. deploymentHosts doc moved off currentHosts' comment; the stale 'refresh() only runs on startup and OnReload' note corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+282
−64
internal/edge/edge.go+18 −15internal/edge/homemap.go+47 −22internal/edge/wildcard.go+15 −5internal/edge/wildcard_parents_test.go+160 −21internal/host/edgesfile.go+6 −1internal/host/edgesfile_test.go+36 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index ebcc77270..d50ce448c 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -717,10 +717,24 @@ func (e *Edge) current() map[string]upstream { |
| // entry carries the PRIMARY's route row verbatim, Platform flag included, so |
| // without the flag a customer domain would inherit its platform primary's |
| // trust and mint a parent for a zone the platform doesn't own. |
| -// deploymentHosts is currentHosts plus every exact name the home map says |
| -// another box homes — the input the wildcard manager's parent set is built |
| -// from (#300). A box with no home map (no deployment store) is its own |
| -// deployment and gets currentHosts alone. |
| +func (e *Edge) currentHosts() []string { |
| + m := e.current() |
| + out := make([]string, 0, len(m)) |
| + for h, up := range m { |
| + if (up.route.Platform || up.route.Provisioned) && !up.alias { |
| + out = append(out, h) |
| + } |
| + } |
| + 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() |
| @@ -740,17 +754,6 @@ func (e *Edge) deploymentHosts() []string { |
| return own |
| } |
| -func (e *Edge) currentHosts() []string { |
| - m := e.current() |
| - out := make([]string, 0, len(m)) |
| - for h, up := range m { |
| - if (up.route.Platform || up.route.Provisioned) && !up.alias { |
| - out = append(out, h) |
| - } |
| - } |
| - return out |
| -} |
| - |
| // 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 |
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go| index 56b7f3a83..abebf535c 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -133,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 |
| @@ -297,45 +304,63 @@ func (m *homeMap) refreshHosts(ctx context.Context) error { |
| m.snap = snap |
| wake := m.hostsWake |
| m.mu.Unlock() |
| - if wake != nil && !sameHostSet(prev.hosts, snap.hosts) { |
| - // The deployment's name set moved: a wildcard parent may have |
| - // appeared for an account homed elsewhere (#300). Outside the lock — |
| - // the wake reads back through Hosts(). |
| - wake() |
| + 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 |
| } |
| -// sameHostSet reports whether two folds name the same hosts (homes may |
| -// differ; only the NAME set feeds the wildcard parents). |
| -func sameHostSet(a, b map[string]homeEntry) bool { |
| - if len(a) != len(b) { |
| - return false |
| - } |
| - for h := range a { |
| - if _, ok := b[h]; !ok { |
| +// 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 !e.platform { |
| + continue |
| + } |
| + na++ |
| + if eb, ok := b[h]; !ok || !eb.platform { |
| return false |
| } |
| } |
| - return true |
| + for _, e := range b { |
| + if e.platform { |
| + nb++ |
| + } |
| + } |
| + return na == nb |
| } |
| -// Hosts is every exact name the deployment's OTHER boxes home — the fold's |
| -// keys. Sorted, nil-safe. The wildcard manager unions these with this box's |
| +// 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. |
| -// parentsFor's two-labels-under-the-apps-domain rule is the trust bound |
| -// here, the same one the box's own registry rows pass through. |
| +// |
| +// 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 { |
| + if m == nil || m.self == "" { |
| return nil |
| } |
| snap := m.snapshot() |
| out := make([]string, 0, len(snap.hosts)) |
| - for h := range snap.hosts { |
| - if strings.HasPrefix(h, "*.") { |
| + for h, e := range snap.hosts { |
| + if !e.platform || strings.HasPrefix(h, "*.") { |
| continue |
| } |
| out = append(out, h) |
| @@ -430,7 +455,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 07b3e07a8..681c3bfa1 100644 |
| --- a/internal/edge/wildcard.go |
| +++ b/internal/edge/wildcard.go |
| @@ -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)) |
| @@ -775,8 +784,9 @@ 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.deploymentHosts 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 |
| + // 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 |
diff --git a/internal/edge/wildcard_parents_test.go b/internal/edge/wildcard_parents_test.go| index fd853d7b1..76bf9e4cb 100644 |
| --- a/internal/edge/wildcard_parents_test.go |
| +++ b/internal/edge/wildcard_parents_test.go |
| @@ -1,56 +1,118 @@ |
| 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" |
| ) |
| -// #300: the home map exposes the deployment's exact names, and a changed |
| -// name set wakes whoever asked (the wildcard manager). |
| +// 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"}}}`) |
| - putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"cribado.bad.apps.test":{"role":"home"},"*.catch.test":{"role":"home"}}}`) |
| + 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") |
| - woke := 0 |
| - m.setHostsWake(func() { woke++ }) |
| + 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 excluded, catch-all excluded)", got, want) |
| - } |
| - if woke != 1 { |
| - t.Fatalf("first fold woke %d times; want 1", woke) |
| + 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 |
| - if woke != 1 { |
| - t.Fatalf("an unchanged refresh woke the manager (%d)", woke) |
| + time.Sleep(20 * time.Millisecond) |
| + if woke.Load() != 1 { |
| + t.Fatalf("an unchanged refresh woke the manager (%d)", woke.Load()) |
| } |
| - putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"3.3.3.3","hosts":{"cribado.bad.apps.test":{"role":"home"},"*.catch.test":{"role":"home"}}}`) |
| - refreshHomeMap(t, m) // home moved, names did not: no wake |
| - if woke != 1 { |
| - t.Fatalf("a moved home woke the manager (%d)", woke) |
| + // 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()) |
| } |
| - putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"4.4.4.4","hosts":{"new.bae.apps.test":{"role":"home"}}}`) |
| + // 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) |
| - if woke != 2 { |
| - t.Fatalf("a new remote host did not wake the manager (%d)", woke) |
| + 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) |
| } |
| + |
| 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"}, "cribado.bad.apps.test", "own.acct.apps.test") |
| + 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) { |
| @@ -65,7 +127,8 @@ func TestDeploymentHostsUnionsTheHomeMap(t *testing.T) { |
| // 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"}, "cribado.bad.apps.test", "foreign.report") |
| + 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) |
| @@ -88,3 +151,79 @@ func TestWildcardParentsIncludeRemoteHomedAccounts(t *testing.T) { |
| 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..09991129b 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -36,6 +36,11 @@ 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 (omitempty): a reader seeing no flag treats the |
| + // host as unclassed and derives no parent from it (#300). |
| + Platform bool `json:"platform,omitempty"` |
| } |
| // edgesFile is the wire shape at edgesFileKey(box), schema v1 (spec Part 3): |
| @@ -116,7 +121,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..4ec99aef9 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -566,3 +566,39 @@ 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"}, |
| + } { |
| + 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) |
| + } |
| + } |
| +} |