| index 04dcffc39..664ae535e 100644 |
| --- a/internal/steering/steering.go |
| +++ b/internal/steering/steering.go |
| @@ -27,12 +27,17 @@ import ( |
| |
| // 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. |
| +// 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 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. |
| +// 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 |
| @@ -154,12 +159,67 @@ func steeredHosts(routes []registry.Route) []registry.Route { |
| 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. |
| +// 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 dedupeRecords |
| +// then has to close. |
| func fqdn(host string) string { |
| - return strings.TrimSuffix(host, ".") + "." |
| + return strings.ToLower(strings.TrimSuffix(host, ".")) + "." |
| +} |
| + |
| +// dedupeRecords collapses records whose (Name, Box) pair collides after |
| +// fqdn's case-fold, keeping the first occurrence (deterministic: both |
| +// steeredHosts and armedEdges hand this function pre-sorted input) and |
| +// logging every collapse loudly — the same discipline every other |
| +// exclusion in this file follows: never guess, never go silent. |
| +// |
| +// This is deliberately narrow, not a general duplicate-input guard, and |
| +// two upstream invariants are why a wider one is not needed: |
| +// |
| +// - 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/<box>.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. |
| +// |
| +// Only a CASE-DIFFERING pair — "App.example.com" and "app.example.com", |
| +// two distinct primary-key rows fqdn now folds onto the same Route53 |
| +// name — can reach this path, which is exactly the collision fqdn's own |
| +// doc comment says it reopens. |
| +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 |
| @@ -170,8 +230,14 @@ func fqdn(host string) string { |
| // |
| // 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. |
| +// minted (there is no domain to build the hostname from) — steered |
| +// per-host records are unaffected either way. 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 { |
| @@ -196,5 +262,5 @@ func desiredState(routes []registry.Route, edges []edgesFileView, appsDomain str |
| } |
| } |
| |
| - return out |
| + return dedupeRecords(out) |
| } |