host: per-box edges file — what this box homes, for the fleet's home map
Task 1 of the multi-region backhaul plan (spec 2026-08-20, Part 3):
each platform box now publishes control/edges/<box>.json — its EIP and
a host -> {role} map for every route it homes, change-gated (a
timestamp-only refresh writes nothing) and armed at the same site and
cadence as registrySnapshotLoop, sharing its box-label derivation
(metricsBoxLabel) rather than duplicating it. Nothing reads the file
yet — inert on today's single-box deployments until Task 3's mirror
ships.
Amends the spec's Part 3 sentence that claimed the file was
serial-bumped: it is change-gated and written by each box's own loop,
never touching control/serial; edges will mirror the prefix on a
30-second poll, matching Part 2's certs ruling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+266
−5
docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md+11 −5internal/host/agent.go+1 −0internal/host/edgesfile.go+121 −0internal/host/edgesfile_test.go+133 −0
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 0cd27bb3a..95ba18281 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 |
| @@ -182,11 +182,17 @@ window ~30 days out. |
| | Home-boxed | exec, unit, socket-activated, tunnel-backed | backhaul to the home edge. The process exists on exactly one box — unchanged invariant | |
| **Home resolution.** Each box maintains `control/edges/<box>.json`: its |
| -EIP and a map `host → {role}` for the routes it homes, written by its |
| -converge loop, serial-bumped on change. v1 writes only `role: "home"`. |
| -Every edge folds these files into a deployment-wide host→home map. The |
| -file doubles as the DR runbook's explicit statement of what a dead box |
| -was serving. |
| +EIP and a map `host → {role}` for the routes it homes. v1 writes only |
| +`role: "home"`. **Amendment (2026-08-22):** written by each box's own |
| +loop (the registry snapshot loop's arming site and cadence, 5min + |
| +jitter), change-gated — a same-content refresh with only a new |
| +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 |
| +doubles as the DR runbook's explicit statement of what a dead box was |
| +serving. |
| **The hop.** A remote edge dials `home-EIP:443` with ordinary SNI — the |
| home edge presents the same bucket-distributed cert. Two headers make it |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 59ac7428a..06f3b0fb7 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -321,6 +321,7 @@ func RunAgent(args []string) { |
| stopHousekeeper := startHousekeeper() |
| go retentionLoop(reg) |
| go registrySnapshotLoop(reg) |
| + go edgesFileLoop(reg) |
| // The sweep reads the registry only through this closure — the stamped |
| // bespoke units to sample beside the carlos- ones, re-read each tick. |
| go metricsLoop(cgm.Root(), func() []string { return stampedUnitNames(reg) }) |
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| new file mode 100644 |
| index 000000000..47a9b7c56 |
| --- /dev/null |
| +++ b/internal/host/edgesfile.go |
| @@ -0,0 +1,121 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "log" |
| + "math/rand/v2" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The multi-region ingress spec (2026-08-20, Part 3, closes the design half |
| +// of #238): every platform box publishes what it homes so any edge can fold |
| +// the fleet's per-box files into a deployment-wide host→home map. v1 writes |
| +// only `role: "home"` — the anticipated read-local role values are Task 3's |
| +// concern, not this file's. |
| +func edgesFileKey(box string) string { return "control/edges/" + box + ".json" } |
| + |
| +// edgeHost is the per-host entry in edgesFile.Hosts. An object, not a bare |
| +// string, is deliberately load-bearing: it is read-local anticipation — |
| +// Task 3's mirror and later roles (e.g. a "read" role) need somewhere to |
| +// grow into without a wire-shape break. |
| +type edgeHost struct { |
| + Role string `json:"role"` |
| +} |
| + |
| +// edgesFile is the wire shape at edgesFileKey(box), schema v1 (spec Part 3): |
| +// |
| +// {"v":1,"box":"annie-welding","eip":"99.81.104.219","generation":"2026-08-22T09:00:00Z","hosts":{"app.example.com":{"role":"home"}}} |
| +type edgesFile struct { |
| + V int `json:"v"` |
| + Box string `json:"box"` |
| + EIP string `json:"eip"` |
| + Generation string `json:"generation"` |
| + Hosts map[string]edgeHost `json:"hosts"` |
| +} |
| + |
| +// edgesFilePass builds this box's edges file from the routes currently in |
| +// reg (every row this box serves, ALL kinds — the serving-class split |
| +// belongs to the reader, Task 3, not to this writer) and publishes it at |
| +// edgesFileKey(box) when it changed. |
| +// |
| +// The change gate is the marshaled body WITH Generation cleared: Generation |
| +// is a fresh RFC3339 stamp every pass, so comparing the full body would |
| +// write on every tick even when the box's homed hosts and EIP are |
| +// identical — a timestamp-only refresh is not a change worth a bucket |
| +// write. *last holds the previous pass's gate body (also Generation- |
| +// cleared) and is updated only after a successful write, mirroring |
| +// registrySnapshotPass's *lastFP contract. |
| +// |
| +// eip may be "" (readPublicIP's "unknown" case, e.g. not on EC2 or IMDS |
| +// disabled) — the file is still published, with `"eip":""`, so the DR |
| +// runbook's hosts list never depends on IMDS working. |
| +func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, box, eip string, last *[]byte) error { |
| + routes, err := reg.List() |
| + if err != nil { |
| + return fmt.Errorf("edges: list: %w", err) |
| + } |
| + hosts := make(map[string]edgeHost, len(routes)) |
| + for _, r := range routes { |
| + hosts[r.Host] = edgeHost{Role: "home"} |
| + } |
| + |
| + f := edgesFile{V: 1, Box: box, EIP: eip, Hosts: hosts} |
| + gate, err := json.Marshal(f) |
| + if err != nil { |
| + return fmt.Errorf("edges: marshal: %w", err) |
| + } |
| + if bytes.Equal(gate, *last) { |
| + return nil |
| + } |
| + |
| + f.Generation = time.Now().UTC().Format(time.RFC3339) |
| + body, err := json.Marshal(f) |
| + if err != nil { |
| + return fmt.Errorf("edges: marshal: %w", err) |
| + } |
| + if err := putReplacing(ctx, s, edgesFileKey(box), body); err != nil { |
| + return err |
| + } |
| + *last = gate |
| + return nil |
| +} |
| + |
| +// edgesFileLoop publishes this box's edges file every 5 minutes + jitter — |
| +// the same cadence and arming site as registrySnapshotLoop (home moves are |
| +// placement events, not per-request events; the backhaul hop's stale-map |
| +// 503 + converge heals faster than this loop could anyway). Platform-box |
| +// job, retentionLoop's rules: skipped on fleet boxes, disabled with a log |
| +// line when there is no deployment bucket. Single-box deployments: the file |
| +// is published and nothing reads it yet — inert until Task 3's mirror |
| +// ships. |
| +func edgesFileLoop(reg *registry.Registry) { |
| + ctx := context.Background() |
| + if shouldSkipPublisherElection(os.Getenv) { |
| + log.Printf("agent: edges file loop is a platform-box job — skipped on a fleet box") |
| + return |
| + } |
| + store, err := openDeployStore(ctx) |
| + if err != nil { |
| + log.Printf("agent: edges file loop disabled: %v", err) |
| + return |
| + } |
| + box := metricsBoxLabel(os.Getenv, readMachineID()) |
| + log.Printf("agent: edges file loop armed (5min + jitter, %s)", edgesFileKey(box)) |
| + var last []byte |
| + delay := time.Minute |
| + for { |
| + time.Sleep(delay) |
| + delay = 5*time.Minute + rand.N(time.Minute) |
| + eip := readPublicIP() |
| + if err := edgesFilePass(ctx, store, reg, box, eip, &last); err != nil { |
| + log.Printf("agent: edges file pass: %v", err) |
| + } |
| + } |
| +} |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| new file mode 100644 |
| index 000000000..172261c80 |
| --- /dev/null |
| +++ b/internal/host/edgesfile_test.go |
| @@ -0,0 +1,133 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func edgesTestRegistry(t *testing.T) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + return reg |
| +} |
| + |
| +// seedRoute upserts a minimal ready route under the given host — any served |
| +// kind does for these tests, since edgesFilePass folds every kind in |
| +// (the serving-class split is the reader's business, Task 3). |
| +func seedRoute(t *testing.T, reg *registry.Registry, host string) { |
| + t.Helper() |
| + if err := reg.Upsert(registry.Route{Host: host, Kind: "service", Addr: "10.0.0.1:3000", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// putCountingStore wraps a release.Store and counts Put calls — the same |
| +// wrap-and-count idiom agent_test.go's countingStore uses for Get, applied |
| +// to Put so the change-gate test can pin "second identical pass writes |
| +// nothing" without inspecting store internals. |
| +type putCountingStore struct { |
| + release.Store |
| + puts int |
| +} |
| + |
| +func (p *putCountingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + p.puts++ |
| + return p.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func TestEdgesFilePublishesHomedHosts(t *testing.T) { |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + seedRoute(t, reg, "app.example.com") |
| + var lastBody []byte |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &lastBody); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var f edgesFile |
| + if err := json.Unmarshal(got, &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + if f.V != 1 || f.Box != "annie" || f.EIP != "1.2.3.4" { |
| + t.Fatalf("file = %+v", f) |
| + } |
| + if r, ok := f.Hosts["app.example.com"]; !ok || r.Role != "home" { |
| + t.Fatalf("hosts = %+v", f.Hosts) |
| + } |
| + if f.Generation == "" { |
| + t.Fatal("generation must be stamped") |
| + } |
| + if got, want := edgesFileKey("annie"), "control/edges/annie.json"; got != want { |
| + t.Fatalf("edgesFileKey = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +// A box with no discoverable public IP still publishes the file, with |
| +// "eip":"" — the DR runbook's hosts list must not depend on IMDS working. |
| +func TestEdgesFilePublishesWithEmptyEIP(t *testing.T) { |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + seedRoute(t, reg, "app.example.com") |
| + var lastBody []byte |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "", &lastBody); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var f edgesFile |
| + if err := json.Unmarshal(got, &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + if f.EIP != "" { |
| + t.Fatalf("eip = %q, want empty", f.EIP) |
| + } |
| + if _, ok := f.Hosts["app.example.com"]; !ok { |
| + t.Fatalf("hosts = %+v, want app.example.com present despite empty eip", f.Hosts) |
| + } |
| +} |
| + |
| +func TestEdgesFileSkipsWriteWhenUnchanged(t *testing.T) { |
| + real := release.NewDirStore(t.TempDir()) |
| + s := &putCountingStore{Store: real} |
| + reg := edgesTestRegistry(t) |
| + seedRoute(t, reg, "app.example.com") |
| + |
| + var lastBody []byte |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &lastBody); err != nil { |
| + t.Fatal(err) |
| + } |
| + if s.puts != 1 { |
| + t.Fatalf("first pass puts = %d, want 1", s.puts) |
| + } |
| + |
| + // Second pass, identical registry state: a fresh generation timestamp |
| + // would differ from the first pass's, but the change-gate excludes |
| + // Generation, so this must not write. |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &lastBody); err != nil { |
| + t.Fatal(err) |
| + } |
| + if s.puts != 1 { |
| + t.Fatalf("unchanged second pass puts = %d, want still 1 (no write)", s.puts) |
| + } |
| + |
| + // A registry change makes the next pass write again. |
| + seedRoute(t, reg, "second.example.com") |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", &lastBody); err != nil { |
| + t.Fatal(err) |
| + } |
| + if s.puts != 2 { |
| + t.Fatalf("changed third pass puts = %d, want 2", s.puts) |
| + } |
| +} |