carlos / platform Public

edgesfile: one reader for the deployment-wide edges picture

internal/host owns what a box PUBLISHES. This package owns what the
console and internal/domains need to READ back across every box: the
same wire shape, decoded once, so the pointing check and the DNS
records answer from the deployment rather than from whichever box
happens to be asking.

The two shapes stay separate types on purpose — the writer can add a
field and roll before every reader understands it, which is what an
additive wire is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 1eb94c25f32d0709b18a0f5018719ef87a7d2b5b parent 4235e9f
2 files changed, +138 −0
  • internal/edgesfile/edgesfile.go +89 −0
  • internal/edgesfile/edgesfile_test.go +49 −0
diff --git a/internal/edgesfile/edgesfile.go b/internal/edgesfile/edgesfile.go
new file mode 100644
index 000000000..15623ecbd
--- /dev/null
+++ b/internal/edgesfile/edgesfile.go
@@ -0,0 +1,89 @@
+// Package edgesfile READS the control/edges/<box>.json wire shape — the
+// per-box publication of what a box homes (multi-region ingress spec, Part
+// 3) — for consumers that need the deployment-wide picture rather than one
+// box's own view.
+//
+// The WRITER's shape lives in internal/host (edgesFile/edgeHost): the box
+// agent owns what it publishes, and this package deliberately does not try
+// to own it too. internal/host cannot host this reader — the console does
+// not import internal/host, and internal/host imports internal/edge and
+// never the reverse — so the reader lives one level down where the console,
+// internal/domains and the box agent can all reach it.
+//
+// The two shapes are joined by a test, not by a shared type: keeping them
+// separate lets the writer add a field and roll before every reader
+// understands it, which is the whole point of an additive wire.
+//
+// Two readers deliberately keep their own narrow decoders and are NOT
+// migrated: internal/edge/homemap.go (the serving hot path, with its own
+// polling and staleness machinery) and internal/steering (which reads no
+// Hosts at all — steering is any-edge-any-app). Both are named here so the
+// consumer list stays complete rather than merely short.
+package edgesfile
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+// Prefix is the flat prefix every box's file lives under.
+const Prefix = "control/edges/"
+
+// RoleHome is the role a box publishes for a host it actually serves. An
+// entry with any other role is either a cert-only role (see Host.Wildcard)
+// or a role a newer build publishes; readers must ignore what they do not
+// recognise rather than route on it or fail the parse.
+const RoleHome = "home"
+
+// Key is the bucket key for box's file.
+func Key(box string) string { return Prefix + box + ".json" }
+
+// Host is one per-host entry, decoded. Every mark is additive, and each
+// defaults to false on a file written before that mark existed — but they
+// fail in OPPOSITE directions, which is deliberate and load-bearing:
+//
+// - Platform absent reads as "not platform", so a pre-mark publisher's rows
+// derive no wildcard parent (#300 fails CLOSED).
+// - Alias absent reads as "not an alias", so a pre-mark publisher's rows
+// keep full route authority and cannot be outranked by a claimed alias
+// (#287 fails OPEN, which is why its readers must roll first).
+//
+// The writer always emits "platform" and omits the other two when false;
+// this reader does not care which, because absent and false decode alike.
+// What the reader must never do is re-serialize a Host and call it a
+// publication — that is the writer's job, and round-tripping through this
+// struct would drop any field a newer box publishes.
+type Host struct {
+ Role string `json:"role"`
+ Platform bool `json:"platform"`
+ Alias bool `json:"alias"`
+ // Wildcard marks a delegated customer wildcard parent (wildcard_domains,
+ // #305): the key is "*.<parent>". With Role RoleHome the box also serves
+ // the catch-all; with any other role it is the cert half alone.
+ Wildcard bool `json:"wildcard"`
+}
+
+// File is one box's published file.
+type File struct {
+ V int `json:"v"`
+ Box string `json:"box"`
+ EIP string `json:"eip"`
+ Region string `json:"region,omitempty"`
+ Generation string `json:"generation,omitempty"`
+ Hosts map[string]Host `json:"hosts"`
+}
+
+// Parse decodes one box's body. Pure — no store I/O. V < 1 (no version field
+// at all) is refused as not a file this reader understands; a HIGHER version
+// is accepted, because the schema grows additively and a newer box's file
+// still carries box/eip/hosts verbatim.
+func Parse(body []byte) (File, error) {
+ var f File
+ if err := json.Unmarshal(body, &f); err != nil {
+ return File{}, fmt.Errorf("edgesfile: parse: %w", err)
+ }
+ if f.V < 1 {
+ return File{}, fmt.Errorf("edgesfile: unversioned edges file")
+ }
+ return f, nil
+}
diff --git a/internal/edgesfile/edgesfile_test.go b/internal/edgesfile/edgesfile_test.go
new file mode 100644
index 000000000..2504b0ed1
--- /dev/null
+++ b/internal/edgesfile/edgesfile_test.go
@@ -0,0 +1,49 @@
+package edgesfile
+
+import "testing"
+
+func TestParseReadsTheWireShape(t *testing.T) {
+ f, err := Parse([]byte(`{"v":1,"box":"annie","eip":"1.1.1.1","region":"eu-west-1",` +
+ `"generation":"2026-08-27T09:00:00Z","hosts":{"a.test":{"role":"home","platform":true},` +
+ `"b.test":{"role":"home","alias":true},` +
+ `"*.c.test":{"role":"wildcard","wildcard":true}}}`))
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if f.Box != "annie" || f.EIP != "1.1.1.1" || f.Region != "eu-west-1" {
+ t.Fatalf("identity fields = %+v", f)
+ }
+ if !f.Hosts["a.test"].Platform || f.Hosts["a.test"].Alias {
+ t.Fatalf("a.test = %+v, want platform-class route", f.Hosts["a.test"])
+ }
+ if !f.Hosts["b.test"].Alias || f.Hosts["b.test"].Platform {
+ t.Fatalf("b.test = %+v, want a claim-derived alias", f.Hosts["b.test"])
+ }
+ // A delegated wildcard parent (#305) decodes with its mark and its own
+ // role. The reader must carry the role through verbatim rather than
+ // normalising it: "wildcard" is the cert half alone, and a reader that
+ // silently read it as RoleHome would dial a box that does not serve the
+ // catch-all.
+ if w := f.Hosts["*.c.test"]; !w.Wildcard || w.Role != "wildcard" {
+ t.Fatalf("*.c.test = %+v, want a cert-only delegated parent", w)
+ }
+}
+
+// V < 1 (no version field at all) is not a file this reader understands; a
+// HIGHER version is accepted, because the schema grows additively and a newer
+// box's file still carries box/eip/hosts verbatim. Mirrors the contract
+// internal/edge/homemap.go and internal/steering already hold.
+func TestParseRefusesUnversionedAndAcceptsNewer(t *testing.T) {
+ if _, err := Parse([]byte(`{"box":"annie","eip":"1.1.1.1"}`)); err == nil {
+ t.Fatal("an unversioned file must be refused")
+ }
+ if _, err := Parse([]byte(`{"v":9,"box":"annie","eip":"1.1.1.1"}`)); err != nil {
+ t.Fatalf("a newer version must still parse: %v", err)
+ }
+}
+
+func TestKeyIsThePrefixedBoxName(t *testing.T) {
+ if got := Key("annie"); got != "control/edges/annie.json" {
+ t.Fatalf("Key = %q", got)
+ }
+}