| 1 | // Package steering builds the desired DNS-steering state for the |
| 2 | // multi-region ingress design's Part 4 (docs/superpowers/specs/ |
| 3 | // 2026-08-20-multi-region-ingress-design.md): a fold of steered routes |
| 4 | // (Route.Steering == "latency") across armed edges (boxes publishing a |
| 5 | // usable control/edges/<box>.json — EIP present, Region a real AWS |
| 6 | // region) into the Route53 latency A records the converger should want. |
| 7 | // |
| 8 | // Everything in this file is a PURE function: no AWS calls, no |
| 9 | // release.Store I/O, no registry writes. desiredState is table-testable |
| 10 | // with plain literals, by design — Task 5 (internal/steering/route53.go) |
| 11 | // is the only place an AWS SDK call is allowed to appear, and Task 6 wired |
| 12 | // this package into the host housekeeper (internal/host/steering_loop.go). |
| 13 | // desiredState is reached only through ConvergeDeployment, and only on a |
| 14 | // deployment that sets CARLOS_STEERING_ZONE_ID — no box does today, so this |
| 15 | // fold is still SINGLE-BOX DORMANT in production. |
| 16 | package steering |
| 17 | |
| 18 | import ( |
| 19 | "encoding/json" |
| 20 | "fmt" |
| 21 | "log" |
| 22 | "regexp" |
| 23 | "sort" |
| 24 | "strings" |
| 25 | |
| 26 | "github.com/carlosframework/platform/internal/registry" |
| 27 | ) |
| 28 | |
| 29 | // A desiredRecord is one latency A record: name -> eip, keyed by the |
| 30 | // edge that serves it. SetIdentifier is the box label; Region is the |
| 31 | // edge's AWS region. Task 5's converger binds each record to a health |
| 32 | // check by EIP (one check per distinct EIP, per the plan's Task 5 |
| 33 | // interfaces) — that binding is looked up there, not carried as a field |
| 34 | // on this type. |
| 35 | // |
| 36 | // Name is ALWAYS a trailing-dot, LOWERCASE FQDN (Route53's own wire |
| 37 | // shape, "app.example.com.", and its own case-insensitivity — DNS names |
| 38 | // are case-insensitive; Route53 itself lowercases on write). desiredState |
| 39 | // normalizes it exactly once, here — Task 5's converger must never |
| 40 | // re-normalize or re-case a Name, and must never treat a bare (dot-less |
| 41 | // or mixed-case) Name as anything but a bug upstream of this fold. |
| 42 | type desiredRecord struct { |
| 43 | Name string // fqdn, trailing-dot normalized |
| 44 | EIP string |
| 45 | Box string // SetIdentifier |
| 46 | Region string // AWS region for latency routing |
| 47 | } |
| 48 | |
| 49 | // edgesFileView is this package's own reader of internal/host's edgesFile |
| 50 | // wire shape (internal/host/edgesfile.go is the writer contract — schema |
| 51 | // v1, one flat object per box at control/edges/<box>.json). A separate |
| 52 | // declaration from internal/edge/homemap.go's own edgesFileView is |
| 53 | // deliberate, not an oversight: internal/steering depends on registry and |
| 54 | // release only (never internal/edge — the import direction the plan |
| 55 | // fixes for this task), and homemap.go's reader does not yet carry the |
| 56 | // Region field this fold needs (it is a Task 1 addition homemap.go has no |
| 57 | // consumer for). Hosts is deliberately absent: steering is any-edge- |
| 58 | // any-app (the spec's home-boxed-kinds-steer-via-backhaul rule). That |
| 59 | // was true until #162: the replica-aware skip in desiredState DOES need to |
| 60 | // know which hosts an edge serves, because the replica mark is exactly what |
| 61 | // removes a record — a box whose file names a steered host with |
| 62 | // replica:true is serving it from a read-only checkout shard, not as a |
| 63 | // home. |
| 64 | |
| 65 | // edgeHostView is one entry of an edges file's hosts map, as the steering |
| 66 | // fold reads it. A named type rather than an anonymous struct so tests and |
| 67 | // readers spell it once; the only field this fold consults is the replica |
| 68 | // mark — role values stay the writer's and homemap's business. |
| 69 | type edgeHostView struct { |
| 70 | Replica bool `json:"replica,omitempty"` |
| 71 | } |
| 72 | |
| 73 | type edgesFileView struct { |
| 74 | V int `json:"v"` |
| 75 | Box string `json:"box"` |
| 76 | EIP string `json:"eip"` |
| 77 | Region string `json:"region,omitempty"` |
| 78 | // Hosts is the per-host map the file publishes (the writer's |
| 79 | // internal/host/edgeHost shape; see edgeHostView for what survives the |
| 80 | // decode). The mark rides the ENTRY (edgeHost.Replica on the writer |
| 81 | // side, #162); a file-level flag would have no meaning — a box can |
| 82 | // serve one account as a home and another as a replica. |
| 83 | Hosts map[string]edgeHostView `json:"hosts"` |
| 84 | } |
| 85 | |
| 86 | // parseEdgesFileView decodes one box's edges file body into the fields |
| 87 | // this package needs. Pure — no store I/O — mirroring |
| 88 | // internal/edge/homemap.go's parseEdgesFile contract: V < 1 (no version |
| 89 | // field at all) is refused as not a file this reader understands; a |
| 90 | // HIGHER version is accepted, because the schema grows additively and a |
| 91 | // newer box's file still carries box/eip/region verbatim. |
| 92 | func parseEdgesFileView(body []byte) (edgesFileView, error) { |
| 93 | var f edgesFileView |
| 94 | if err := json.Unmarshal(body, &f); err != nil { |
| 95 | return edgesFileView{}, fmt.Errorf("steering: parse edges file: %w", err) |
| 96 | } |
| 97 | if f.V < 1 { |
| 98 | return edgesFileView{}, fmt.Errorf("steering: unversioned edges file") |
| 99 | } |
| 100 | return f, nil |
| 101 | } |
| 102 | |
| 103 | // awsRegionPattern matches an AWS region name shape (e.g. "eu-west-1", |
| 104 | // "us-gov-west-1") — never a CARLOS placement code ("ie", "fleet/<name>"). |
| 105 | // Task 1's RULED block (plan review catch, 2026-08-23): a bare non-empty |
| 106 | // check would let a placement code leak straight into a |
| 107 | // ChangeResourceRecordSets call, which Route53 would reject batch-wide. |
| 108 | // The gate matches SHAPE, never mere presence. |
| 109 | var awsRegionPattern = regexp.MustCompile(`^[a-z]{2}(-[a-z]+)+-\d$`) |
| 110 | |
| 111 | // armedEdges filters edges to the set that can receive latency records: |
| 112 | // EIP present (there is an address to answer with) and Region matching |
| 113 | // the AWS-region shape. An edge failing either check is UNARMED for |
| 114 | // steering — logged once per edge, naming the bad value, so a leaking |
| 115 | // placement code (or a probe that never resolved) is loud rather than |
| 116 | // silently folded into a record nobody can trust. The result is sorted by |
| 117 | // Box so desiredState's output is deterministic regardless of input |
| 118 | // order (harmless for Route53, load-bearing for tests). |
| 119 | func armedEdges(edges []edgesFileView) []edgesFileView { |
| 120 | out := make([]edgesFileView, 0, len(edges)) |
| 121 | for _, e := range edges { |
| 122 | if e.EIP == "" { |
| 123 | log.Printf("steering: edge %s unarmed: no EIP", e.Box) |
| 124 | continue |
| 125 | } |
| 126 | if !awsRegionPattern.MatchString(e.Region) { |
| 127 | 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) |
| 128 | continue |
| 129 | } |
| 130 | out = append(out, e) |
| 131 | } |
| 132 | sort.Slice(out, func(i, j int) bool { return out[i].Box < out[j].Box }) |
| 133 | return out |
| 134 | } |
| 135 | |
| 136 | // steeredHosts filters routes to the concrete hosts eligible for a |
| 137 | // latency record: |
| 138 | // |
| 139 | // - Steering must be exactly "latency" (provision.ValidSteering's own |
| 140 | // vocabulary, not re-imported here — see the package doc for the |
| 141 | // import-direction rule). "" is the common, unopted-in case and is |
| 142 | // skipped silently; anything else is hand-written bucket state (a |
| 143 | // value no door would ever write) and is skipped LOUDLY. |
| 144 | // - The host must not be a wildcard row ("*.parent") — steering is |
| 145 | // per-concrete-host (the spec's per-host DNS rule: a wildcard cannot |
| 146 | // resolve to more than one A record set without per-request |
| 147 | // knowledge Route53 does not have). Registry wildcard entries live in |
| 148 | // route_aliases today, never in Route.Host, so this is a defensive |
| 149 | // check, not a reachable one — kept anyway, loudly, per the plan. |
| 150 | // - Pool must be "" (the shared pool). The console door and CLI already |
| 151 | // refuse steering:"latency" on a pool-scoped route (Task 3); this |
| 152 | // fold refuses it AGAIN, because a hand-edited bucket record can |
| 153 | // still carry both — the door guards the write path, not the bucket. |
| 154 | // - The host must lie under appsDomain — equal to it, or ending in |
| 155 | // "."+appsDomain. ValidRecordHost (internal/provision) deliberately |
| 156 | // allows an honestly opted-in, out-of-zone host through the door (an |
| 157 | // addrs record, shared pool, e.g. a console's own hostname on a |
| 158 | // domain the deployment does not hold the Route53 zone for) — that |
| 159 | // check is about path-traversal safety, not about which zone a name |
| 160 | // lives in, and it has no reason to know. A steered route on such a |
| 161 | // host reaching Route53 is what jams the fold: ChangeResourceRecordSets |
| 162 | // rejects a name "not contained in" the target zone (or the call fails |
| 163 | // AccessDenied under a name-bound policy), and every record in that |
| 164 | // batch — every OTHER steered host too — fails with it, EVERY tick, |
| 165 | // forever, because desiredState hands converge one batch and Route53 |
| 166 | // applies a batch atomically. This check is the fold's own zone |
| 167 | // boundary, independent of what the door already allows: a steered |
| 168 | // host outside it is excluded, loudly, naming the CNAME path that |
| 169 | // actually reaches the shared pool — custom domains steer via CNAME to |
| 170 | // edge.<apps-domain>; a direct Route53 record only ever lives in the |
| 171 | // steering zone itself. |
| 172 | // |
| 173 | // The result is sorted by Host, then run through dedupeCaseCollisions — |
| 174 | // see that function's doc for why the case-fold collision check belongs |
| 175 | // HERE and not later, once records exist. |
| 176 | func steeredHosts(routes []registry.Route, appsDomain string) []registry.Route { |
| 177 | out := make([]registry.Route, 0, len(routes)) |
| 178 | for _, r := range routes { |
| 179 | switch r.Steering { |
| 180 | case "": |
| 181 | continue |
| 182 | case "latency": |
| 183 | // candidate — remaining checks below |
| 184 | default: |
| 185 | log.Printf("steering: route %s excluded: invalid steering value %q", r.Host, r.Steering) |
| 186 | continue |
| 187 | } |
| 188 | if strings.HasPrefix(r.Host, "*.") { |
| 189 | log.Printf("steering: route %s excluded: wildcard host cannot receive a per-host latency record", r.Host) |
| 190 | continue |
| 191 | } |
| 192 | if r.Pool != "" { |
| 193 | log.Printf("steering: route %s excluded: pool %q set (steering requires the shared pool)", r.Host, r.Pool) |
| 194 | continue |
| 195 | } |
| 196 | if appsDomain == "" { |
| 197 | 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.<apps-domain>, and a direct Route53 record only ever lives in the steering zone itself", r.Host) |
| 198 | continue |
| 199 | } |
| 200 | if !inAppsZone(r.Host, appsDomain) { |
| 201 | log.Printf("steering: route %s excluded: host is not under the apps domain %q — custom domains steer via CNAME to edge.<apps-domain>; 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) |
| 202 | continue |
| 203 | } |
| 204 | out = append(out, r) |
| 205 | } |
| 206 | sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) |
| 207 | return dedupeCaseCollisions(out) |
| 208 | } |
| 209 | |
| 210 | // inAppsZone reports whether host lies under appsDomain — equal to it, or a |
| 211 | // subdomain of it — case-insensitively and trailing-dot-tolerant (registry |
| 212 | // Host values and appsDomain both arrive un-normalized; fqdn's own |
| 213 | // normalization runs after this check, not before, so this compares raw |
| 214 | // input on both sides itself). appsDomain arrives already trimmed of |
| 215 | // whitespace and a trailing dot (desiredState does that once, before any |
| 216 | // call here). |
| 217 | func inAppsZone(host, appsDomain string) bool { |
| 218 | h := strings.ToLower(strings.TrimSuffix(host, ".")) |
| 219 | d := strings.ToLower(appsDomain) |
| 220 | return h == d || strings.HasSuffix(h, "."+d) |
| 221 | } |
| 222 | |
| 223 | // dedupeCaseCollisions drops the second of any two routes whose Host |
| 224 | // values differ only by case — "App.example.com" and "app.example.com" |
| 225 | // both fold to the same DNS name via fqdn (the lowercasing FINDING 1 |
| 226 | // added). This check runs HERE, inside steeredHosts, rather than later |
| 227 | // once desiredRecords exist, because ONLY here do both original Host |
| 228 | // strings — the two registry primary keys an operator actually has to go |
| 229 | // fix — still exist to name in the log. By the time records are built and |
| 230 | // crossed with edges, a same-edge collision has Box/EIP identical for |
| 231 | // both rows too, so a log at that layer can only repeat the one already- |
| 232 | // folded Name and a redundant EIP, forcing the operator to go query the |
| 233 | // registry to find which two rows collided. Naming the collision at its |
| 234 | // source is the whole fix; dedupeRecords below stays as a structural |
| 235 | // backstop that this check makes unreachable for the case-fold path. |
| 236 | // |
| 237 | // The kept row is the first in Host's sort order (deterministic, matching |
| 238 | // every other exclusion in this file) — which one wins is arbitrary; that |
| 239 | // a decision is made, logged, and reproducible is what matters. |
| 240 | func dedupeCaseCollisions(routes []registry.Route) []registry.Route { |
| 241 | if len(routes) == 0 { |
| 242 | return routes |
| 243 | } |
| 244 | seen := make(map[string]string, len(routes)) // lowercase host -> first Host kept, original case |
| 245 | out := make([]registry.Route, 0, len(routes)) |
| 246 | for _, r := range routes { |
| 247 | lower := strings.ToLower(r.Host) |
| 248 | if kept, ok := seen[lower]; ok { |
| 249 | 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)) |
| 250 | continue |
| 251 | } |
| 252 | seen[lower] = r.Host |
| 253 | out = append(out, r) |
| 254 | } |
| 255 | return out |
| 256 | } |
| 257 | |
| 258 | // fqdn normalizes host to a trailing-dot, LOWERCASE FQDN, Route53's own |
| 259 | // wire shape — exactly once, here, so every desiredRecord.Name downstream |
| 260 | // consumer (Task 5) never has to reason about whether a Name is already |
| 261 | // normalized OR already case-folded. |
| 262 | // |
| 263 | // Lowercasing matters for the same reason Steering/Pool/wildcard-shape |
| 264 | // already get a defensive, logged check in this file: registry.Host is a |
| 265 | // case-SENSITIVE primary key (internal/registry/registry.go), but DNS — |
| 266 | // and Route53's own record names — are not. Two registry rows differing |
| 267 | // only by case ("App.example.com" and "app.example.com") are two |
| 268 | // distinct, legitimate primary-key rows right up until this fold would |
| 269 | // otherwise hand Route53 two "different" names for what every resolver |
| 270 | // on earth treats as the same host. Folding here, not refusing, is the |
| 271 | // right answer — case is an accident of how a hostname was typed, not a |
| 272 | // meaningful distinction — but it does reopen a collision: |
| 273 | // dedupeCaseCollisions (steeredHosts) closes it while both original Host |
| 274 | // strings are still around to name in the log; dedupeRecords is the |
| 275 | // backstop below that. |
| 276 | func fqdn(host string) string { |
| 277 | return strings.ToLower(strings.TrimSuffix(host, ".")) + "." |
| 278 | } |
| 279 | |
| 280 | // dedupeRecords collapses records whose (Name, Box) pair collides, |
| 281 | // keeping the first occurrence (deterministic: both steeredHosts and |
| 282 | // armedEdges hand desiredState pre-sorted input) and logging every |
| 283 | // collapse — the same discipline every other exclusion in this file |
| 284 | // follows: never guess, never go silent. |
| 285 | // |
| 286 | // This is a STRUCTURAL BACKSTOP, not the primary defense, and is |
| 287 | // deliberately narrow rather than a general duplicate-input guard. Three |
| 288 | // invariants are why: |
| 289 | // |
| 290 | // - registry.Route.Host is the routes table's PRIMARY KEY |
| 291 | // (internal/registry/registry.go's `routes` schema) — two rows with |
| 292 | // BYTE-IDENTICAL Host values cannot coexist, so steeredHosts can |
| 293 | // never hand desiredState two already-identical hosts. |
| 294 | // - one edges file per box, at control/edges/<box>.json |
| 295 | // (internal/host/edgesfile.go's edgesFileKey) — a box's OWN filename |
| 296 | // is its identity in the listing this reads, so two edgesFileView |
| 297 | // entries with byte-identical Box values would mean the same stored |
| 298 | // object parsed twice, not two boxes racing to claim one label. |
| 299 | // - the CASE-DIFFERING pair that fqdn's lowercasing (FINDING 1) could |
| 300 | // otherwise fold together — "App.example.com" and |
| 301 | // "app.example.com" — is now caught EARLIER, by |
| 302 | // dedupeCaseCollisions inside steeredHosts, while both original Host |
| 303 | // strings still exist to name in the log. This function's own log |
| 304 | // line is therefore UNREACHABLE for that path today (it fires only |
| 305 | // if some future caller builds desiredRecords by a route other than |
| 306 | // steeredHosts — e.g. a second producer added without running |
| 307 | // through the same dedupe); it stays as the backstop that fails loud |
| 308 | // rather than silent if that ever happens, not as live code any |
| 309 | // current test exercises via a case collision. |
| 310 | func dedupeRecords(records []desiredRecord) []desiredRecord { |
| 311 | if len(records) == 0 { |
| 312 | return records |
| 313 | } |
| 314 | type key struct{ name, box string } |
| 315 | seen := make(map[key]desiredRecord, len(records)) |
| 316 | out := make([]desiredRecord, 0, len(records)) |
| 317 | for _, rec := range records { |
| 318 | k := key{rec.Name, rec.Box} |
| 319 | if kept, ok := seen[k]; ok { |
| 320 | 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", |
| 321 | rec.Name, rec.Box, rec.EIP, kept.EIP) |
| 322 | continue |
| 323 | } |
| 324 | seen[k] = rec |
| 325 | out = append(out, rec) |
| 326 | } |
| 327 | return out |
| 328 | } |
| 329 | |
| 330 | // desiredState folds steered routes × armed edges. The steering |
| 331 | // hostname edge.<apps-domain> is ALWAYS present when any edge is |
| 332 | // armed (custom-domain CNAME target). Non-shared-pool routes are |
| 333 | // excluded defensively (the door refuses them; the bucket may not |
| 334 | // have) with a loud log. |
| 335 | // |
| 336 | // appsDomain empty with at least one armed edge is treated the same as |
| 337 | // any other missing input: no synthetic edge.<apps-domain> records are |
| 338 | // minted (there is no domain to build the hostname from), AND every |
| 339 | // per-host steered route is excluded too (steeredHosts' own appsDomain |
| 340 | // check) — with no apps domain there is no zone to test a host against, |
| 341 | // so no per-host name can be proven in-zone, and only an in-zone name may |
| 342 | // ever be written (see steeredHosts). The skip is logged on EVERY call, |
| 343 | // not throttled: this function has no state to remember a prior call's |
| 344 | // log across ticks (it is pure), so a box misconfigured with no apps |
| 345 | // domain logs this once per housekeeper pass — the same convention every |
| 346 | // other loud skip in this file follows (no dedup window), and a |
| 347 | // deliberate choice: a duty this infrequent (Task 6's 5min+jitter |
| 348 | // cadence) does not need throttling to stay readable. |
| 349 | func desiredState(routes []registry.Route, edges []edgesFileView, appsDomain string) []desiredRecord { |
| 350 | armed := armedEdges(edges) |
| 351 | if len(armed) == 0 { |
| 352 | return nil |
| 353 | } |
| 354 | |
| 355 | var out []desiredRecord |
| 356 | // TrimSpace OUTSIDE TrimSuffix, deliberately: TrimSuffix only strips a |
| 357 | // dot that is the very last byte, so the other order (TrimSuffix then |
| 358 | // TrimSpace) would trim the dot before the space and leave |
| 359 | // "example.com. "'s dot behind after the space comes off. Unreachable |
| 360 | // today — the one production caller (internal/host/steering_loop.go) |
| 361 | // already does strings.TrimSpace(os.Getenv(...)) before handing |
| 362 | // appsDomain to ConvergeDeployment — but a wrong order here would |
| 363 | // misfire silently the day that stops being true. |
| 364 | domain := strings.TrimSuffix(strings.TrimSpace(appsDomain), ".") |
| 365 | |
| 366 | if domain == "" { |
| 367 | log.Printf("steering: no apps domain configured — the edge.<apps-domain> steering hostname is not minted this pass") |
| 368 | } else { |
| 369 | edgeHost := fqdn("edge." + domain) |
| 370 | for _, e := range armed { |
| 371 | out = append(out, desiredRecord{Name: edgeHost, EIP: e.EIP, Box: e.Box, Region: e.Region}) |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | for _, r := range steeredHosts(routes, domain) { |
| 376 | name := fqdn(r.Host) |
| 377 | // Replica-aware skip (#162): steering is any-edge-any-app (a far edge |
| 378 | // terminates and BACKHAULS to the home — that is the whole design), |
| 379 | // so every armed edge earns a record for a steered host exactly as it |
| 380 | // did before #162, and the ONLY entries removed are the replica mark's: |
| 381 | // a box whose file names the host WITH replica:true is serving it from |
| 382 | // a read-only checkout shard, and steering buyers' whole sessions there |
| 383 | // would be wrong in a way backhaul is not. Entries with no replica |
| 384 | // mark — every pre-#162 file, every home — keep their records |
| 385 | // byte-identically. |
| 386 | for _, e := range armed { |
| 387 | if h, named := e.Hosts[r.Host]; named && h.Replica { |
| 388 | continue |
| 389 | } |
| 390 | out = append(out, desiredRecord{Name: name, EIP: e.EIP, Box: e.Box, Region: e.Region}) |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | return dedupeRecords(out) |
| 395 | } |
| 396 | |