// 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/.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 wired // this package into the host housekeeper (internal/host/steering_loop.go). // desiredState is reached only through ConvergeDeployment, and only on a // deployment that sets CARLOS_STEERING_ZONE_ID — no box does today, so this // fold is still SINGLE-BOX DORMANT in production. 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. Task 5's converger binds each record to a health // check by EIP (one check per distinct EIP, per the plan's Task 5 // interfaces) — that binding is looked up there, not carried as a field // on this type. // // Name is ALWAYS a trailing-dot, LOWERCASE FQDN (Route53's own wire // shape, "app.example.com.", and its own case-insensitivity — DNS names // are case-insensitive; Route53 itself lowercases on write). desiredState // normalizes it exactly once, here — Task 5's converger must never // re-normalize or re-case a Name, and must never treat a bare (dot-less // or mixed-case) 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/.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). That // was true until #162: the replica-aware skip in desiredState DOES need to // know which hosts an edge serves, because the replica mark is exactly what // removes a record — a box whose file names a steered host with // replica:true is serving it from a read-only checkout shard, not as a // home. // edgeHostView is one entry of an edges file's hosts map, as the steering // fold reads it. A named type rather than an anonymous struct so tests and // readers spell it once; the only field this fold consults is the replica // mark — role values stay the writer's and homemap's business. type edgeHostView struct { Replica bool `json:"replica,omitempty"` } type edgesFileView struct { V int `json:"v"` Box string `json:"box"` EIP string `json:"eip"` Region string `json:"region,omitempty"` // Hosts is the per-host map the file publishes (the writer's // internal/host/edgeHost shape; see edgeHostView for what survives the // decode). The mark rides the ENTRY (edgeHost.Replica on the writer // side, #162); a file-level flag would have no meaning — a box can // serve one account as a home and another as a replica. Hosts map[string]edgeHostView `json:"hosts"` } // 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/"). // 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 host must lie under appsDomain — equal to it, or ending in // "."+appsDomain. ValidRecordHost (internal/provision) deliberately // allows an honestly opted-in, out-of-zone host through the door (an // addrs record, shared pool, e.g. a console's own hostname on a // domain the deployment does not hold the Route53 zone for) — that // check is about path-traversal safety, not about which zone a name // lives in, and it has no reason to know. A steered route on such a // host reaching Route53 is what jams the fold: ChangeResourceRecordSets // rejects a name "not contained in" the target zone (or the call fails // AccessDenied under a name-bound policy), and every record in that // batch — every OTHER steered host too — fails with it, EVERY tick, // forever, because desiredState hands converge one batch and Route53 // applies a batch atomically. This check is the fold's own zone // boundary, independent of what the door already allows: a steered // host outside it is excluded, loudly, naming the CNAME path that // actually reaches the shared pool — custom domains steer via CNAME to // edge.; a direct Route53 record only ever lives in the // steering zone itself. // // The result is sorted by Host, then run through dedupeCaseCollisions — // see that function's doc for why the case-fold collision check belongs // HERE and not later, once records exist. func steeredHosts(routes []registry.Route, appsDomain string) []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 } if appsDomain == "" { log.Printf("steering: route %s excluded: no apps domain configured — only an in-zone host can receive a direct steering record; custom domains steer via CNAME to edge., and a direct Route53 record only ever lives in the steering zone itself", r.Host) continue } if !inAppsZone(r.Host, appsDomain) { log.Printf("steering: route %s excluded: host is not under the apps domain %q — custom domains steer via CNAME to edge.; a direct Route53 record only ever lives in the steering zone itself (Route53 would reject the whole batch, wedging every other steered host too)", r.Host, appsDomain) continue } out = append(out, r) } sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) return dedupeCaseCollisions(out) } // inAppsZone reports whether host lies under appsDomain — equal to it, or a // subdomain of it — case-insensitively and trailing-dot-tolerant (registry // Host values and appsDomain both arrive un-normalized; fqdn's own // normalization runs after this check, not before, so this compares raw // input on both sides itself). appsDomain arrives already trimmed of // whitespace and a trailing dot (desiredState does that once, before any // call here). func inAppsZone(host, appsDomain string) bool { h := strings.ToLower(strings.TrimSuffix(host, ".")) d := strings.ToLower(appsDomain) return h == d || strings.HasSuffix(h, "."+d) } // dedupeCaseCollisions drops the second of any two routes whose Host // values differ only by case — "App.example.com" and "app.example.com" // both fold to the same DNS name via fqdn (the lowercasing FINDING 1 // added). This check runs HERE, inside steeredHosts, rather than later // once desiredRecords exist, because ONLY here do both original Host // strings — the two registry primary keys an operator actually has to go // fix — still exist to name in the log. By the time records are built and // crossed with edges, a same-edge collision has Box/EIP identical for // both rows too, so a log at that layer can only repeat the one already- // folded Name and a redundant EIP, forcing the operator to go query the // registry to find which two rows collided. Naming the collision at its // source is the whole fix; dedupeRecords below stays as a structural // backstop that this check makes unreachable for the case-fold path. // // The kept row is the first in Host's sort order (deterministic, matching // every other exclusion in this file) — which one wins is arbitrary; that // a decision is made, logged, and reproducible is what matters. func dedupeCaseCollisions(routes []registry.Route) []registry.Route { if len(routes) == 0 { return routes } seen := make(map[string]string, len(routes)) // lowercase host -> first Host kept, original case out := make([]registry.Route, 0, len(routes)) for _, r := range routes { lower := strings.ToLower(r.Host) if kept, ok := seen[lower]; ok { log.Printf("steering: case-collision: %q and %q fold to the same DNS name %q — keeping the first, fix the registry rows", kept, r.Host, fqdn(r.Host)) continue } seen[lower] = r.Host out = append(out, r) } return out } // fqdn normalizes host to a trailing-dot, LOWERCASE 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 OR already case-folded. // // Lowercasing matters for the same reason Steering/Pool/wildcard-shape // already get a defensive, logged check in this file: registry.Host is a // case-SENSITIVE primary key (internal/registry/registry.go), but DNS — // and Route53's own record names — are not. Two registry rows differing // only by case ("App.example.com" and "app.example.com") are two // distinct, legitimate primary-key rows right up until this fold would // otherwise hand Route53 two "different" names for what every resolver // on earth treats as the same host. Folding here, not refusing, is the // right answer — case is an accident of how a hostname was typed, not a // meaningful distinction — but it does reopen a collision: // dedupeCaseCollisions (steeredHosts) closes it while both original Host // strings are still around to name in the log; dedupeRecords is the // backstop below that. func fqdn(host string) string { return strings.ToLower(strings.TrimSuffix(host, ".")) + "." } // dedupeRecords collapses records whose (Name, Box) pair collides, // keeping the first occurrence (deterministic: both steeredHosts and // armedEdges hand desiredState pre-sorted input) and logging every // collapse — the same discipline every other exclusion in this file // follows: never guess, never go silent. // // This is a STRUCTURAL BACKSTOP, not the primary defense, and is // deliberately narrow rather than a general duplicate-input guard. Three // invariants are why: // // - registry.Route.Host is the routes table's PRIMARY KEY // (internal/registry/registry.go's `routes` schema) — two rows with // BYTE-IDENTICAL Host values cannot coexist, so steeredHosts can // never hand desiredState two already-identical hosts. // - one edges file per box, at control/edges/.json // (internal/host/edgesfile.go's edgesFileKey) — a box's OWN filename // is its identity in the listing this reads, so two edgesFileView // entries with byte-identical Box values would mean the same stored // object parsed twice, not two boxes racing to claim one label. // - the CASE-DIFFERING pair that fqdn's lowercasing (FINDING 1) could // otherwise fold together — "App.example.com" and // "app.example.com" — is now caught EARLIER, by // dedupeCaseCollisions inside steeredHosts, while both original Host // strings still exist to name in the log. This function's own log // line is therefore UNREACHABLE for that path today (it fires only // if some future caller builds desiredRecords by a route other than // steeredHosts — e.g. a second producer added without running // through the same dedupe); it stays as the backstop that fails loud // rather than silent if that ever happens, not as live code any // current test exercises via a case collision. func dedupeRecords(records []desiredRecord) []desiredRecord { if len(records) == 0 { return records } type key struct{ name, box string } seen := make(map[key]desiredRecord, len(records)) out := make([]desiredRecord, 0, len(records)) for _, rec := range records { k := key{rec.Name, rec.Box} if kept, ok := seen[k]; ok { log.Printf("steering: record %s (box %s) dropped: case-folds to the same Route53 name as an already-kept record (EIP %s, kept EIP %s) — two registry rows differ only by case", rec.Name, rec.Box, rec.EIP, kept.EIP) continue } seen[k] = rec out = append(out, rec) } return out } // desiredState folds steered routes × armed edges. The steering // hostname edge. 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. records are // minted (there is no domain to build the hostname from), AND every // per-host steered route is excluded too (steeredHosts' own appsDomain // check) — with no apps domain there is no zone to test a host against, // so no per-host name can be proven in-zone, and only an in-zone name may // ever be written (see steeredHosts). The skip is logged on EVERY call, // not throttled: this function has no state to remember a prior call's // log across ticks (it is pure), so a box misconfigured with no apps // domain logs this once per housekeeper pass — the same convention every // other loud skip in this file follows (no dedup window), and a // deliberate choice: a duty this infrequent (Task 6's 5min+jitter // cadence) does not need throttling to stay readable. func desiredState(routes []registry.Route, edges []edgesFileView, appsDomain string) []desiredRecord { armed := armedEdges(edges) if len(armed) == 0 { return nil } var out []desiredRecord // TrimSpace OUTSIDE TrimSuffix, deliberately: TrimSuffix only strips a // dot that is the very last byte, so the other order (TrimSuffix then // TrimSpace) would trim the dot before the space and leave // "example.com. "'s dot behind after the space comes off. Unreachable // today — the one production caller (internal/host/steering_loop.go) // already does strings.TrimSpace(os.Getenv(...)) before handing // appsDomain to ConvergeDeployment — but a wrong order here would // misfire silently the day that stops being true. domain := strings.TrimSuffix(strings.TrimSpace(appsDomain), ".") if domain == "" { log.Printf("steering: no apps domain configured — the edge. 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, domain) { name := fqdn(r.Host) // Replica-aware skip (#162): steering is any-edge-any-app (a far edge // terminates and BACKHAULS to the home — that is the whole design), // so every armed edge earns a record for a steered host exactly as it // did before #162, and the ONLY entries removed are the replica mark's: // a box whose file names the host WITH replica:true is serving it from // a read-only checkout shard, and steering buyers' whole sessions there // would be wrong in a way backhaul is not. Entries with no replica // mark — every pre-#162 file, every home — keep their records // byte-identically. for _, e := range armed { if h, named := e.Hosts[r.Host]; named && h.Replica { continue } out = append(out, desiredRecord{Name: name, EIP: e.EIP, Box: e.Box, Region: e.Region}) } } return dedupeRecords(out) }