steering: desired state — steered hosts × armed edges
Task 4 of the multi-region-steering plan: the pure desiredState fold
(internal/steering/steering.go). Consumes registry.Route rows and this
package's own edgesFileView reader (internal/host/edgesfile.go is the
writer contract), producing []desiredRecord — one latency A record per
steered host per armed edge, plus the always-present
edge.<apps-domain> steering hostname whenever >=1 edge is armed.
Armed gate validates the AWS-region shape (^[a-z]{2}(-[a-z]+)+-\d$),
never bare non-empty (Task 1's RULED block) — a CARLOS placement code
leaking through is refused and logged by value, not silently accepted.
Steering values outside ""/"latency", wildcard registry rows, and
pool-carrying steered routes are all excluded defensively with a loud
log naming the offending row. Names are normalized to a trailing-dot
FQDN exactly once here, so Task 5's converger never has to.
Pure functions only, no AWS/store I/O; single-box inert (zero callers
until Task 6 wires this into the housekeeper duty).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+471
−0
internal/steering/steering.go+200 −0internal/steering/steering_test.go+271 −0
diff --git a/internal/steering/steering.go b/internal/steering/steering.go| new file mode 100644 |
| index 000000000..04dcffc39 |
| --- /dev/null |
| +++ b/internal/steering/steering.go |
| @@ -0,0 +1,200 @@ |
| +// Package steering builds the desired DNS-steering state for the |
| +// multi-region ingress design's Part 4 (docs/superpowers/specs/ |
| +// 2026-08-20-multi-region-ingress-design.md): a fold of steered routes |
| +// (Route.Steering == "latency") across armed edges (boxes publishing a |
| +// usable control/edges/<box>.json — EIP present, Region a real AWS |
| +// region) into the Route53 latency A records the converger should want. |
| +// |
| +// Everything in this file is a PURE function: no AWS calls, no |
| +// release.Store I/O, no registry writes. desiredState is table-testable |
| +// with plain literals, by design — Task 5 (internal/steering/route53.go) |
| +// is the only place an AWS SDK call is allowed to appear, and Task 6 wires |
| +// this package into the host housekeeper. As of this task, nothing calls |
| +// desiredState: it is SINGLE-BOX INERT, exactly like every other stage of |
| +// this plan until its own wiring task lands. |
| +package steering |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "log" |
| + "regexp" |
| + "sort" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// A desiredRecord is one latency A record: name -> eip, keyed by the |
| +// edge that serves it. SetIdentifier is the box label; Region is the |
| +// edge's AWS region; HealthCheckRef ties it to the edge's check. |
| +// |
| +// Name is ALWAYS a trailing-dot FQDN (Route53's own wire shape, |
| +// "app.example.com."). desiredState normalizes it exactly once, here — |
| +// Task 5's converger must never re-normalize a Name, and must never treat |
| +// a bare (dot-less) Name as anything but a bug upstream of this fold. |
| +type desiredRecord struct { |
| + Name string // fqdn, trailing-dot normalized |
| + EIP string |
| + Box string // SetIdentifier |
| + Region string // AWS region for latency routing |
| +} |
| + |
| +// edgesFileView is this package's own reader of internal/host's edgesFile |
| +// wire shape (internal/host/edgesfile.go is the writer contract — schema |
| +// v1, one flat object per box at control/edges/<box>.json). A separate |
| +// declaration from internal/edge/homemap.go's own edgesFileView is |
| +// deliberate, not an oversight: internal/steering depends on registry and |
| +// release only (never internal/edge — the import direction the plan |
| +// fixes for this task), and homemap.go's reader does not yet carry the |
| +// Region field this fold needs (it is a Task 1 addition homemap.go has no |
| +// consumer for). Hosts is deliberately absent: steering is any-edge- |
| +// any-app (the spec's home-boxed-kinds-steer-via-backhaul rule), so this |
| +// fold never needs to know which hosts an edge homes. |
| +type edgesFileView struct { |
| + V int `json:"v"` |
| + Box string `json:"box"` |
| + EIP string `json:"eip"` |
| + Region string `json:"region,omitempty"` |
| +} |
| + |
| +// parseEdgesFileView decodes one box's edges file body into the fields |
| +// this package needs. Pure — no store I/O — mirroring |
| +// internal/edge/homemap.go's parseEdgesFile contract: 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/region verbatim. |
| +func parseEdgesFileView(body []byte) (edgesFileView, error) { |
| + var f edgesFileView |
| + if err := json.Unmarshal(body, &f); err != nil { |
| + return edgesFileView{}, fmt.Errorf("steering: parse edges file: %w", err) |
| + } |
| + if f.V < 1 { |
| + return edgesFileView{}, fmt.Errorf("steering: unversioned edges file") |
| + } |
| + return f, nil |
| +} |
| + |
| +// awsRegionPattern matches an AWS region name shape (e.g. "eu-west-1", |
| +// "us-gov-west-1") — never a CARLOS placement code ("ie", "fleet/<name>"). |
| +// Task 1's RULED block (plan review catch, 2026-08-23): a bare non-empty |
| +// check would let a placement code leak straight into a |
| +// ChangeResourceRecordSets call, which Route53 would reject batch-wide. |
| +// The gate matches SHAPE, never mere presence. |
| +var awsRegionPattern = regexp.MustCompile(`^[a-z]{2}(-[a-z]+)+-\d$`) |
| + |
| +// armedEdges filters edges to the set that can receive latency records: |
| +// EIP present (there is an address to answer with) and Region matching |
| +// the AWS-region shape. An edge failing either check is UNARMED for |
| +// steering — logged once per edge, naming the bad value, so a leaking |
| +// placement code (or a probe that never resolved) is loud rather than |
| +// silently folded into a record nobody can trust. The result is sorted by |
| +// Box so desiredState's output is deterministic regardless of input |
| +// order (harmless for Route53, load-bearing for tests). |
| +func armedEdges(edges []edgesFileView) []edgesFileView { |
| + out := make([]edgesFileView, 0, len(edges)) |
| + for _, e := range edges { |
| + if e.EIP == "" { |
| + log.Printf("steering: edge %s unarmed: no EIP", e.Box) |
| + continue |
| + } |
| + if !awsRegionPattern.MatchString(e.Region) { |
| + log.Printf("steering: edge %s unarmed: region %q is not an AWS region (a CARLOS placement code cannot receive latency records)", e.Box, e.Region) |
| + continue |
| + } |
| + out = append(out, e) |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].Box < out[j].Box }) |
| + return out |
| +} |
| + |
| +// steeredHosts filters routes to the concrete hosts eligible for a |
| +// latency record: |
| +// |
| +// - Steering must be exactly "latency" (provision.ValidSteering's own |
| +// vocabulary, not re-imported here — see the package doc for the |
| +// import-direction rule). "" is the common, unopted-in case and is |
| +// skipped silently; anything else is hand-written bucket state (a |
| +// value no door would ever write) and is skipped LOUDLY. |
| +// - The host must not be a wildcard row ("*.parent") — steering is |
| +// per-concrete-host (the spec's per-host DNS rule: a wildcard cannot |
| +// resolve to more than one A record set without per-request |
| +// knowledge Route53 does not have). Registry wildcard entries live in |
| +// route_aliases today, never in Route.Host, so this is a defensive |
| +// check, not a reachable one — kept anyway, loudly, per the plan. |
| +// - Pool must be "" (the shared pool). The console door and CLI already |
| +// refuse steering:"latency" on a pool-scoped route (Task 3); this |
| +// fold refuses it AGAIN, because a hand-edited bucket record can |
| +// still carry both — the door guards the write path, not the bucket. |
| +// |
| +// The result is sorted by Host for the same determinism armedEdges keeps. |
| +func steeredHosts(routes []registry.Route) []registry.Route { |
| + out := make([]registry.Route, 0, len(routes)) |
| + for _, r := range routes { |
| + switch r.Steering { |
| + case "": |
| + continue |
| + case "latency": |
| + // candidate — remaining checks below |
| + default: |
| + log.Printf("steering: route %s excluded: invalid steering value %q", r.Host, r.Steering) |
| + continue |
| + } |
| + if strings.HasPrefix(r.Host, "*.") { |
| + log.Printf("steering: route %s excluded: wildcard host cannot receive a per-host latency record", r.Host) |
| + continue |
| + } |
| + if r.Pool != "" { |
| + log.Printf("steering: route %s excluded: pool %q set (steering requires the shared pool)", r.Host, r.Pool) |
| + continue |
| + } |
| + out = append(out, r) |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) |
| + return out |
| +} |
| + |
| +// fqdn normalizes host to a trailing-dot FQDN, Route53's own wire shape — |
| +// exactly once, here, so every desiredRecord.Name downstream consumer |
| +// (Task 5) never has to reason about whether a Name is already |
| +// normalized. |
| +func fqdn(host string) string { |
| + return strings.TrimSuffix(host, ".") + "." |
| +} |
| + |
| +// desiredState folds steered routes × armed edges. The steering |
| +// hostname edge.<apps-domain> is ALWAYS present when any edge is |
| +// armed (custom-domain CNAME target). Non-shared-pool routes are |
| +// excluded defensively (the door refuses them; the bucket may not |
| +// have) with a loud log. |
| +// |
| +// appsDomain empty with at least one armed edge is treated the same as |
| +// any other missing input: no synthetic edge.<apps-domain> records are |
| +// minted (there is no domain to build the hostname from), logged once — |
| +// steered per-host records are unaffected either way. |
| +func desiredState(routes []registry.Route, edges []edgesFileView, appsDomain string) []desiredRecord { |
| + armed := armedEdges(edges) |
| + if len(armed) == 0 { |
| + return nil |
| + } |
| + |
| + var out []desiredRecord |
| + |
| + if domain := strings.TrimSpace(strings.TrimSuffix(appsDomain, ".")); domain == "" { |
| + log.Printf("steering: no apps domain configured — the edge.<apps-domain> steering hostname is not minted this pass") |
| + } else { |
| + edgeHost := fqdn("edge." + domain) |
| + for _, e := range armed { |
| + out = append(out, desiredRecord{Name: edgeHost, EIP: e.EIP, Box: e.Box, Region: e.Region}) |
| + } |
| + } |
| + |
| + for _, r := range steeredHosts(routes) { |
| + name := fqdn(r.Host) |
| + for _, e := range armed { |
| + out = append(out, desiredRecord{Name: name, EIP: e.EIP, Box: e.Box, Region: e.Region}) |
| + } |
| + } |
| + |
| + return out |
| +} |
diff --git a/internal/steering/steering_test.go b/internal/steering/steering_test.go| new file mode 100644 |
| index 000000000..0ad59e7f4 |
| --- /dev/null |
| +++ b/internal/steering/steering_test.go |
| @@ -0,0 +1,271 @@ |
| +package steering |
| + |
| +import ( |
| + "bytes" |
| + "log" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func captureLog(t *testing.T) *bytes.Buffer { |
| + t.Helper() |
| + var buf bytes.Buffer |
| + prev := log.Writer() |
| + flags := log.Flags() |
| + log.SetOutput(&buf) |
| + log.SetFlags(0) |
| + t.Cleanup(func() { log.SetOutput(prev); log.SetFlags(flags) }) |
| + return &buf |
| +} |
| + |
| +func steeredRoute(host string) registry.Route { |
| + return registry.Route{Host: host, Steering: "latency"} |
| +} |
| + |
| +func edge(box, eip, region string) edgesFileView { |
| + return edgesFileView{V: 1, Box: box, EIP: eip, Region: region} |
| +} |
| + |
| +// TestDesiredState is the brief's table: one steered host x two armed |
| +// edges = two records; unsteered host absent; edge.<apps-domain> present |
| +// whenever >=1 edge is armed, absent at zero. |
| +func TestDesiredState(t *testing.T) { |
| + dublin := edge("dublin", "1.2.3.4", "eu-west-1") |
| + oregon := edge("oregon", "5.6.7.8", "us-west-2") |
| + |
| + tests := []struct { |
| + name string |
| + routes []registry.Route |
| + edges []edgesFileView |
| + appsDomain string |
| + want []desiredRecord |
| + }{ |
| + { |
| + name: "one steered host x two armed edges", |
| + routes: []registry.Route{steeredRoute("app.example.com")}, |
| + edges: []edgesFileView{dublin, oregon}, |
| + appsDomain: "example.com", |
| + want: []desiredRecord{ |
| + {Name: "edge.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + {Name: "edge.example.com.", EIP: "5.6.7.8", Box: "oregon", Region: "us-west-2"}, |
| + {Name: "app.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + {Name: "app.example.com.", EIP: "5.6.7.8", Box: "oregon", Region: "us-west-2"}, |
| + }, |
| + }, |
| + { |
| + name: "unsteered host absent", |
| + routes: []registry.Route{ |
| + {Host: "app.example.com", Steering: ""}, |
| + steeredRoute("steered.example.com"), |
| + }, |
| + edges: []edgesFileView{dublin}, |
| + appsDomain: "example.com", |
| + want: []desiredRecord{ |
| + {Name: "edge.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + {Name: "steered.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + }, |
| + }, |
| + { |
| + name: "zero armed edges yields nothing, no edge.<apps-domain>", |
| + routes: []registry.Route{steeredRoute("app.example.com")}, |
| + edges: nil, |
| + appsDomain: "example.com", |
| + want: nil, |
| + }, |
| + { |
| + name: "edge with empty EIP skipped", |
| + routes: []registry.Route{steeredRoute("app.example.com")}, |
| + edges: []edgesFileView{dublin, edge("noeip", "", "us-west-2")}, |
| + appsDomain: "example.com", |
| + want: []desiredRecord{ |
| + {Name: "edge.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + {Name: "app.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + }, |
| + }, |
| + { |
| + name: "edge with empty region skipped", |
| + routes: []registry.Route{steeredRoute("app.example.com")}, |
| + edges: []edgesFileView{dublin, edge("noregion", "9.9.9.9", "")}, |
| + appsDomain: "example.com", |
| + want: []desiredRecord{ |
| + {Name: "edge.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + {Name: "app.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + }, |
| + }, |
| + { |
| + name: "edge region carrying a placement code (not an AWS region) skipped", |
| + routes: []registry.Route{steeredRoute("app.example.com")}, |
| + edges: []edgesFileView{dublin, edge("tito", "9.9.9.9", "ie")}, |
| + appsDomain: "example.com", |
| + want: []desiredRecord{ |
| + {Name: "edge.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + {Name: "app.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + }, |
| + }, |
| + { |
| + name: "pool-carrying steered route excluded", |
| + routes: []registry.Route{ |
| + {Host: "pooled.example.com", Steering: "latency", Pool: "reserved"}, |
| + }, |
| + edges: []edgesFileView{dublin}, |
| + appsDomain: "example.com", |
| + want: []desiredRecord{ |
| + {Name: "edge.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + }, |
| + }, |
| + { |
| + name: "wildcard registry row with steering set never produces a record", |
| + routes: []registry.Route{ |
| + {Host: "*.example.com", Steering: "latency"}, |
| + }, |
| + edges: []edgesFileView{dublin}, |
| + appsDomain: "example.com", |
| + want: []desiredRecord{ |
| + {Name: "edge.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + }, |
| + }, |
| + { |
| + name: "invalid steering value skipped, never guessed", |
| + routes: []registry.Route{ |
| + {Host: "hand-written.example.com", Steering: "geo"}, |
| + }, |
| + edges: []edgesFileView{dublin}, |
| + appsDomain: "example.com", |
| + want: []desiredRecord{ |
| + {Name: "edge.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + }, |
| + }, |
| + { |
| + name: "trailing-dot input normalizes to a single trailing dot", |
| + routes: []registry.Route{steeredRoute("app.example.com.")}, |
| + edges: []edgesFileView{dublin}, |
| + appsDomain: "example.com.", |
| + want: []desiredRecord{ |
| + {Name: "edge.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + {Name: "app.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}, |
| + }, |
| + }, |
| + } |
| + |
| + for _, tt := range tests { |
| + t.Run(tt.name, func(t *testing.T) { |
| + captureLog(t) // silence + isolate log output from test noise |
| + got := desiredState(tt.routes, tt.edges, tt.appsDomain) |
| + if !reflect.DeepEqual(got, tt.want) { |
| + t.Fatalf("desiredState() =\n%#v\nwant\n%#v", got, tt.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestDesiredStateLogsEachSkipReason(t *testing.T) { |
| + dublin := edge("dublin", "1.2.3.4", "eu-west-1") |
| + |
| + tests := []struct { |
| + name string |
| + routes []registry.Route |
| + edges []edgesFileView |
| + wantLog string |
| + }{ |
| + { |
| + name: "edge with no EIP is named in the log", |
| + edges: []edgesFileView{edge("noeip", "", "eu-west-1")}, |
| + wantLog: "steering: edge noeip unarmed: no EIP", |
| + }, |
| + { |
| + name: "edge with a placement code names the bad value", |
| + edges: []edgesFileView{edge("tito", "9.9.9.9", "ie")}, |
| + wantLog: `steering: edge tito unarmed: region "ie" is not an AWS region`, |
| + }, |
| + { |
| + name: "pool-carrying route is named", |
| + edges: []edgesFileView{dublin}, |
| + routes: []registry.Route{{Host: "pooled.example.com", Steering: "latency", Pool: "reserved"}}, |
| + wantLog: `steering: route pooled.example.com excluded: pool "reserved" set`, |
| + }, |
| + { |
| + name: "wildcard route is named", |
| + edges: []edgesFileView{dublin}, |
| + routes: []registry.Route{{Host: "*.example.com", Steering: "latency"}}, |
| + wantLog: "steering: route *.example.com excluded: wildcard host", |
| + }, |
| + { |
| + name: "invalid steering value is named verbatim", |
| + edges: []edgesFileView{dublin}, |
| + routes: []registry.Route{{Host: "hand.example.com", Steering: "geo"}}, |
| + wantLog: `steering: route hand.example.com excluded: invalid steering value "geo"`, |
| + }, |
| + } |
| + |
| + for _, tt := range tests { |
| + t.Run(tt.name, func(t *testing.T) { |
| + buf := captureLog(t) |
| + desiredState(tt.routes, tt.edges, "example.com") |
| + if !strings.Contains(buf.String(), tt.wantLog) { |
| + t.Fatalf("log output =\n%s\nwant substring %q", buf.String(), tt.wantLog) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestParseEdgesFileView(t *testing.T) { |
| + tests := []struct { |
| + name string |
| + body string |
| + want edgesFileView |
| + wantErr bool |
| + }{ |
| + { |
| + name: "v1 body with region", |
| + body: `{"v":1,"box":"dublin","eip":"1.2.3.4","region":"eu-west-1","generation":"2026-08-22T09:00:00Z","hosts":{"app.example.com":{"role":"home"}}}`, |
| + want: edgesFileView{V: 1, Box: "dublin", EIP: "1.2.3.4", Region: "eu-west-1"}, |
| + }, |
| + { |
| + name: "v1 body without region (pre-region writer)", |
| + body: `{"v":1,"box":"dublin","eip":"1.2.3.4","generation":"2026-08-22T09:00:00Z","hosts":{}}`, |
| + want: edgesFileView{V: 1, Box: "dublin", EIP: "1.2.3.4", Region: ""}, |
| + }, |
| + { |
| + name: "unversioned body refused", |
| + body: `{"box":"dublin","eip":"1.2.3.4"}`, |
| + wantErr: true, |
| + }, |
| + { |
| + name: "garbage body refused", |
| + body: `not json`, |
| + wantErr: true, |
| + }, |
| + } |
| + |
| + for _, tt := range tests { |
| + t.Run(tt.name, func(t *testing.T) { |
| + got, err := parseEdgesFileView([]byte(tt.body)) |
| + if tt.wantErr { |
| + if err == nil { |
| + t.Fatalf("parseEdgesFileView(%q) = %+v, nil; want error", tt.body, got) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatalf("parseEdgesFileView(%q) unexpected error: %v", tt.body, err) |
| + } |
| + if got != tt.want { |
| + t.Fatalf("parseEdgesFileView(%q) = %+v, want %+v", tt.body, got, tt.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestArmedEdgesSortedByBox(t *testing.T) { |
| + edges := []edgesFileView{ |
| + edge("oregon", "5.6.7.8", "us-west-2"), |
| + edge("dublin", "1.2.3.4", "eu-west-1"), |
| + } |
| + got := armedEdges(edges) |
| + if len(got) != 2 || got[0].Box != "dublin" || got[1].Box != "oregon" { |
| + t.Fatalf("armedEdges() = %+v, want dublin then oregon", got) |
| + } |
| +} |