carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
16package steering
17
18import (
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.
42type 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.
69type edgeHostView struct {
70 Replica bool `json:"replica,omitempty"`
71}
72
73type 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.
92func 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.
109var 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).
119func 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.
176func 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).
217func 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.
240func 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.
276func 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.
310func 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.
349func 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