carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 26e206b71f6d1ed4fdab1cc2e58c3d06ea787198 parent 05d9811
6 files changed, +282 −64
  • internal/edge/edge.go +18 −15
  • internal/edge/homemap.go +47 −22
  • internal/edge/wildcard.go +15 −5
  • internal/edge/wildcard_parents_test.go +160 −21
  • internal/host/edgesfile.go +6 −1
  • internal/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)
+ }
+ }
+}