| index 00adefda6..ba22f9604 100644 |
| --- a/internal/steering/route53.go |
| +++ b/internal/steering/route53.go |
| @@ -56,6 +56,11 @@ const ( |
| // own 1000-change cap. Smaller batches mean a failure retries less work |
| // and names a smaller blast radius in the log. |
| changeBatchLimit = 100 |
| + |
| + // shrinkFloor is the number of managed records a pass may always remove, |
| + // however small the zone. Without it, the proportional guard below would |
| + // make a 2-record zone unable to lose a single record. See shrinkLimit. |
| + shrinkFloor = 2 |
| ) |
| |
| // steeringZonePattern is the region-host tofu module's own zone-id shape |
| @@ -129,9 +134,12 @@ type ownedCheck struct { |
| // before anything is written, and an empty result means the managed |
| // record set is empty, which means NOTHING is deletable; |
| // 2. read the zone; |
| -// 3. CREATE any missing health check — capacity before records need it; |
| -// 4. UPSERT wanted records, DELETE managed-but-unwanted ones; |
| -// 5. GC health checks nothing references any more — after the records that |
| +// 3. drop names Route53 will not let us write (plainConflicts) and decide |
| +// what the pass would delete — BOTH REFUSALS RUN BEFORE THE FIRST WRITE, |
| +// so a refused pass is genuinely zero-write, not merely zero-delete; |
| +// 4. CREATE any missing health check — capacity before records need it; |
| +// 5. UPSERT wanted records, DELETE managed-but-unwanted ones; |
| +// 6. GC health checks nothing references any more — after the records that |
| // referenced them are gone, never before. |
| // |
| // A failure at any step returns loudly and leaves the rest of the pass |
| @@ -153,6 +161,10 @@ func Converge(ctx context.Context, client route53API, zoneID string, want []desi |
| // set on that signal would turn a transient read failure into a global |
| // outage. So: refuse, loudly, and let the next tick decide again. |
| // A genuine full teardown is an operator action. |
| + // |
| + // This is the degenerate case of the proportional guard below; both |
| + // exist because "delete everything" and "delete most things" are the |
| + // same mistake at different sizes. |
| if len(want) == 0 { |
| log.Printf("steering: empty desired set for zone %s — refusing to remove any record (a wholesale teardown is an operator action, not a converge outcome)", zoneID) |
| return nil |
| @@ -179,7 +191,28 @@ func Converge(ctx context.Context, client route53API, zoneID string, want []desi |
| return err |
| } |
| |
| - // 3. health checks for every wanted EIP. |
| + // 3a. names Route53 forbids us from writing at all. |
| + conflicted := plainConflicts(want, existing) |
| + if len(conflicted) > 0 { |
| + want = dropNames(want, conflicted) |
| + if len(want) == 0 { |
| + log.Printf("steering: every steered name in zone %s collides with a plain record — refusing to change anything", zoneID) |
| + return nil |
| + } |
| + } |
| + |
| + // 3b. the proportional shrink guard. |
| + managed, unwanted := partitionManaged(want, existing, ownedIDs, conflicted) |
| + if limit := shrinkLimit(managed); len(unwanted) > limit { |
| + for _, rr := range unwanted { |
| + log.Printf("steering: would delete %s (set %s, value %s)", aws.ToString(rr.Name), aws.ToString(rr.SetIdentifier), firstValue(rr)) |
| + } |
| + log.Printf("steering: refusing to converge zone %s: the pass would delete %d of %d managed records (limit %d) — a shrink that large is far likelier a short read of edges or routes than an intent to withdraw those names, and a genuine teardown that size is an operator action, exactly like the empty-desired-set case; the intended deletions are listed above", |
| + zoneID, len(unwanted), managed, limit) |
| + return nil |
| + } |
| + |
| + // 4. health checks for every wanted EIP. |
| sni := steeringHostname(want) |
| for _, eip := range distinctEIPs(want) { |
| if _, ok := checkByEIP[eip]; ok { |
| @@ -198,8 +231,8 @@ func Converge(ctx context.Context, client route53API, zoneID string, want []desi |
| owned = append(owned, ownedCheck{id: id, eip: eip}) |
| } |
| |
| - // 4. records. |
| - changes, err := recordChanges(want, existing, checkByEIP, ownedIDs) |
| + // 5. records. |
| + changes, err := recordChanges(want, existing, checkByEIP, ownedIDs, unwanted) |
| if err != nil { |
| return err |
| } |
| @@ -207,10 +240,38 @@ func Converge(ctx context.Context, client route53API, zoneID string, want []desi |
| return err |
| } |
| |
| - // 5. GC. Runs only once the record batches have landed. |
| + // 6. GC. Runs only once the record batches have landed. |
| return gcHealthChecks(ctx, client, owned, want, existing, changes) |
| } |
| |
| +// shrinkLimit is how many managed records one pass may delete, given how many |
| +// it currently manages: half of them, but never fewer than shrinkFloor. |
| +// |
| +// FINDING 1 (review, 2026-08-22). The empty-desired-set refusal above only |
| +// catches a read that returned NOTHING. A SHORT read — three of five edges' |
| +// files listed, or a routes query that came back missing a steered host — has |
| +// a non-empty want and sails straight past it, and the converger would |
| +// dutifully delete every record belonging to the members that went missing. |
| +// For a live steered host that is NXDOMAIN until the next tick: not a slow |
| +// failover, a hard outage, caused by a transient read rather than by anything |
| +// an operator did. |
| +// |
| +// The durable fix is fail-closed reads upstream (Task 6's half: a partial |
| +// listing must produce an ERROR, not a smaller slice). This is the half that |
| +// belongs here — a blast-radius cap that holds even if a caller one day hands |
| +// us a quietly-truncated set. A pass that would remove most of what it manages |
| +// stops and describes itself instead. |
| +// |
| +// The floor keeps small zones workable: a 2-record zone losing one record is |
| +// ordinary, and a purely proportional rule would refuse it forever. The floor |
| +// is small enough that the cap still bites at any size worth protecting. |
| +func shrinkLimit(managed int) int { |
| + if half := managed / 2; half > shrinkFloor { |
| + return half |
| + } |
| + return shrinkFloor |
| +} |
| + |
| // --------------------------------------------------------------------------- |
| // ownership |
| // --------------------------------------------------------------------------- |
| @@ -559,7 +620,111 @@ func sameRRSet(have, want r53types.ResourceRecordSet) bool { |
| return true |
| } |
| |
| -// recordChanges diffs want against the zone. |
| +// plainConflicts returns the wanted names the zone already holds a PLAIN A |
| +// record for — one with no SetIdentifier — lowercased with the trailing dot. |
| +// |
| +// FINDING 2 (review, 2026-08-22). Route53 will not let a name+type hold BOTH |
| +// a simple record and a routing-policy record set: submitting a latency A for |
| +// a name that already has a plain A is rejected with InvalidChangeBatch. And |
| +// change batches are ATOMIC — the rejection takes the whole batch with it. So |
| +// a single operator-made A record at a name somebody later steers would wedge |
| +// every steering record in the zone, on every tick, forever: the pass would |
| +// fail, retry identically, and fail again, while unrelated names never got |
| +// their records. |
| +// |
| +// The resolution is to skip the conflicting NAME entirely — no upsert, no |
| +// delete — and say so loudly. Never the alternative of deleting the plain |
| +// record to make room: it is an operator's record (no SetIdentifier means |
| +// static/operator territory, the same rule isManaged enforces), and taking a |
| +// name away from its owner because a member typed it into a steering field |
| +// is not a call this code gets to make. |
| +func plainConflicts(want []desiredRecord, existing []r53types.ResourceRecordSet) map[string]bool { |
| + wantNames := make(map[string]bool, len(want)) |
| + for _, rec := range want { |
| + wantNames[strings.ToLower(rec.Name)] = true |
| + } |
| + conflicted := map[string]bool{} |
| + for _, rr := range existing { |
| + if rr.Type != r53types.RRTypeA || aws.ToString(rr.SetIdentifier) != "" { |
| + continue |
| + } |
| + name := strings.ToLower(aws.ToString(rr.Name)) |
| + if !wantNames[name] || conflicted[name] { |
| + continue |
| + } |
| + conflicted[name] = true |
| + log.Printf("steering: SKIPPING %s entirely: the zone already holds a plain A record there (value %s, TTL %d) and Route53 forbids a name from holding both a plain record and a latency record set — steering this name needs that record removed by its owner first", |
| + aws.ToString(rr.Name), firstValue(rr), aws.ToInt64(rr.TTL)) |
| + } |
| + return conflicted |
| +} |
| + |
| +// dropNames removes every desired record whose Name is in the set. |
| +func dropNames(want []desiredRecord, names map[string]bool) []desiredRecord { |
| + out := make([]desiredRecord, 0, len(want)) |
| + for _, rec := range want { |
| + if names[strings.ToLower(rec.Name)] { |
| + continue |
| + } |
| + out = append(out, rec) |
| + } |
| + return out |
| +} |
| + |
| +// partitionManaged counts the zone's carlos-managed records and collects the |
| +// ones no desired key wants — the pass's would-be deletions. It is separated |
| +// from recordChanges so Converge can weigh the deletions BEFORE it creates a |
| +// single health check, which is what makes the shrink guard's refusal |
| +// genuinely zero-write. |
| +// |
| +// Records at a conflicted name are excluded from both halves: the pass has |
| +// already promised not to touch that name. That exclusion is a STRUCTURAL |
| +// BACKSTOP and is deliberately untested — a conflicted name is one holding a |
| +// plain record, and Route53 forbids a plain record and a latency record set |
| +// from sharing a name, so a MANAGED record at a conflicted name is a state |
| +// the service cannot produce. It is kept, in the same spirit as |
| +// dedupeRecords in steering.go, so that a misread listing fails by leaving |
| +// records alone rather than by deleting our own. |
| +func partitionManaged(want []desiredRecord, existing []r53types.ResourceRecordSet, ownedIDs, conflicted map[string]bool) (managed int, unwanted []r53types.ResourceRecordSet) { |
| + wanted := make(map[string]bool, len(want)) |
| + for _, rec := range want { |
| + wanted[wantKey(rec)] = true |
| + } |
| + for _, rr := range existing { |
| + if conflicted[strings.ToLower(aws.ToString(rr.Name))] { |
| + continue |
| + } |
| + if !isManaged(rr, ownedIDs) { |
| + // Say so when it LOOKS like ours but fails the ownership gate — |
| + // a latency A record with a SetIdentifier whose health check we |
| + // do not own is either an operator's or an orphan of ours whose |
| + // check went missing. Either way we do not touch it, and either |
| + // way somebody should know it is there. |
| + if rr.Type == r53types.RRTypeA && aws.ToString(rr.SetIdentifier) != "" && rr.Region != "" && rr.AliasTarget == nil { |
| + log.Printf("steering: leaving latency record %s (set %s) alone: health check %q is not one this deployment owns", |
| + aws.ToString(rr.Name), aws.ToString(rr.SetIdentifier), aws.ToString(rr.HealthCheckId)) |
| + } |
| + continue |
| + } |
| + managed++ |
| + if !wanted[recordKey(rr)] { |
| + unwanted = append(unwanted, rr) |
| + } |
| + } |
| + return managed, unwanted |
| +} |
| + |
| +// firstValue renders a record set's first value for a log line. |
| +func firstValue(rr r53types.ResourceRecordSet) string { |
| + if len(rr.ResourceRecords) == 0 { |
| + return "?" |
| + } |
| + return aws.ToString(rr.ResourceRecords[0].Value) |
| +} |
| + |
| +// recordChanges diffs want against the zone. The deletions were decided |
| +// already, by partitionManaged, and are passed in — this function only turns |
| +// them into changes. |
| // |
| // UPSERTs come first in the returned slice and DELETEs last, so that when the |
| // changes are chunked, capacity is added before any is taken away. |
| @@ -568,14 +733,17 @@ func sameRRSet(have, want r53types.ResourceRecordSet) bool { |
| // latency record at exactly the (name, SetIdentifier) we would create) is |
| // ADOPTED by the UPSERT — CREATE would collide with it and there is no third |
| // option. It is logged, because taking over a record is the one write here |
| -// that changes something we did not make. |
| -func recordChanges(want []desiredRecord, existing []r53types.ResourceRecordSet, checkByEIP map[string]string, ownedIDs map[string]bool) ([]r53types.Change, error) { |
| +// that changes something we did not make. Note the difference from the |
| +// plain-record conflict above: adoption replaces a record of the SAME routing |
| +// policy at the SAME key, which Route53 permits and which is the record we |
| +// would otherwise have created; the plain-record case is a different policy |
| +// at the same name, which Route53 refuses outright. |
| +func recordChanges(want []desiredRecord, existing []r53types.ResourceRecordSet, checkByEIP map[string]string, ownedIDs map[string]bool, unwanted []r53types.ResourceRecordSet) ([]r53types.Change, error) { |
| byKey := make(map[string]r53types.ResourceRecordSet, len(existing)) |
| for _, rr := range existing { |
| byKey[recordKey(rr)] = rr |
| } |
| |
| - wanted := make(map[string]bool, len(want)) |
| var upserts []r53types.Change |
| for _, rec := range want { |
| checkID, ok := checkByEIP[rec.EIP] |
| @@ -585,10 +753,8 @@ func recordChanges(want []desiredRecord, existing []r53types.ResourceRecordSet, |
| // record with no health check, which would never withdraw. |
| return nil, fmt.Errorf("steering: no health check for %s (record %s / %s)", rec.EIP, rec.Name, rec.Box) |
| } |
| - key := wantKey(rec) |
| - wanted[key] = true |
| desired := desiredRRSet(rec, checkID) |
| - have, present := byKey[key] |
| + have, present := byKey[wantKey(rec)] |
| if present && sameRRSet(have, desired) { |
| continue |
| } |
| @@ -598,26 +764,10 @@ func recordChanges(want []desiredRecord, existing []r53types.ResourceRecordSet, |
| upserts = append(upserts, r53types.Change{Action: r53types.ChangeActionUpsert, ResourceRecordSet: &desired}) |
| } |
| |
| - var deletes []r53types.Change |
| - for _, rr := range existing { |
| - key := recordKey(rr) |
| - if wanted[key] { |
| - continue |
| - } |
| - if isManaged(rr, ownedIDs) { |
| - set := rr // DELETE must resubmit the record set verbatim |
| - deletes = append(deletes, r53types.Change{Action: r53types.ChangeActionDelete, ResourceRecordSet: &set}) |
| - continue |
| - } |
| - // Not managed. Say so when it LOOKS like ours but fails the |
| - // ownership gate — a latency A record with a SetIdentifier whose |
| - // health check we do not own is either an operator's or an orphan |
| - // of ours whose check went missing. Either way we do not touch it, |
| - // and either way somebody should know it is there. |
| - if rr.Type == r53types.RRTypeA && aws.ToString(rr.SetIdentifier) != "" && rr.Region != "" && rr.AliasTarget == nil { |
| - log.Printf("steering: leaving latency record %s (set %s) alone: health check %q is not one this deployment owns", |
| - aws.ToString(rr.Name), aws.ToString(rr.SetIdentifier), aws.ToString(rr.HealthCheckId)) |
| - } |
| + deletes := make([]r53types.Change, 0, len(unwanted)) |
| + for _, rr := range unwanted { |
| + set := rr // DELETE must resubmit the record set verbatim |
| + deletes = append(deletes, r53types.Change{Action: r53types.ChangeActionDelete, ResourceRecordSet: &set}) |
| } |
| |
| return append(upserts, deletes...), nil |