steering: fix round 2 — sibling wedges, adoption log regression, SNI downgrade
FINDING A: F2's cure was incomplete. Two sibling shapes at a wanted name
have the identical failure mode — the write is rejected and takes the whole
atomic batch with it, wedging every steering record in the zone forever:
- a CNAME (exclusive in DNS: a name holding one holds no other type). A
member steering a hostname that already CNAMEs inside the steering zone
is the ordinary custom-domain shape, not an exotic case.
- a NON-LATENCY policy A set (SetIdentifier set, Region empty —
weighted/failover/geo/geoproximity/CIDR/multivalue). One routing policy
per name+type.
plainConflicts becomes conflictedNames, with the three shapes enumerated in
conflictReason, sharing one cure: skip the name, log which shape is in the
way and (for a policy set) WHICH policy. The fake's validator models all
three refusals on the projected post-batch state, so atomicity is modelled
rather than asserted. Left open on the report, deliberately not guessed at:
an ALIAS latency record at exactly one wanted (name, SetIdentifier), which
Route53 also refuses to convert in place — that is a KEY-level conflict, and
dropping the whole NAME for it would refuse names that are steerable.
FINDING B (Low, round-1 regression): partitionManaged lost recordChanges'
wanted[key] guard when the loop was split out, so "leaving latency record X
alone" fired for the very record the same pass ADOPTS — two log lines
claiming opposite fates for one record. Restored the guard. The adoption
test now asserts the "leaving alone" line does NOT appear, and a new test
holds the line up for a foreign record the pass genuinely does not touch, so
the fix cannot silence it everywhere.
Doc/observability: the SNI fallback site now logs its downgrade once. When
the synthetic edge.<apps-domain> name is itself skipped for a conflict,
every check created afterwards silently rides one member's name and
certificate instead of the shared steering hostname — the log names the
fallback host and points at the skip above as the cause.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+399
−73
internal/steering/route53.go+118 −29internal/steering/route53_test.go+281 −44
diff --git a/internal/steering/route53.go b/internal/steering/route53.go| index ba22f9604..5985d5d2c 100644 |
| --- a/internal/steering/route53.go |
| +++ b/internal/steering/route53.go |
| @@ -192,11 +192,11 @@ func Converge(ctx context.Context, client route53API, zoneID string, want []desi |
| } |
| // 3a. names Route53 forbids us from writing at all. |
| - conflicted := plainConflicts(want, existing) |
| + conflicted := conflictedNames(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) |
| + log.Printf("steering: every steered name in zone %s collides with an existing record — refusing to change anything", zoneID) |
| return nil |
| } |
| } |
| @@ -214,6 +214,7 @@ func Converge(ctx context.Context, client route53API, zoneID string, want []desi |
| // 4. health checks for every wanted EIP. |
| sni := steeringHostname(want) |
| + loggedSNIFallback := false |
| for _, eip := range distinctEIPs(want) { |
| if _, ok := checkByEIP[eip]; ok { |
| continue |
| @@ -221,6 +222,22 @@ func Converge(ctx context.Context, client route53API, zoneID string, want []desi |
| fqdnFor := sni |
| if fqdnFor == "" { |
| fqdnFor = fallbackHostname(want, eip) |
| + // The shared steering hostname is the RIGHT SNI name because |
| + // every edge holds a certificate for it — a check on it tests |
| + // the path every visitor takes. Falling back to one member's |
| + // hostname is a real downgrade: the check now depends on that |
| + // one member's name and certificate, so if the member later |
| + // moves or its route is removed, the check starts failing for |
| + // reasons that have nothing to do with the edge's health. |
| + // |
| + // Note the interaction worth seeing in a log: if the synthetic |
| + // edge.<apps-domain> name was DROPPED for a record conflict just |
| + // above, this is where that shows up — a name-level conflict |
| + // silently degrading every health check created afterwards. |
| + if !loggedSNIFallback { |
| + log.Printf("steering: no edge.<apps-domain> record in the desired set — new health checks fall back to a member hostname (%s) for SNI instead of the shared steering hostname; if that name was skipped for a record conflict above, THAT is the cause, and the check now rides one member's name and certificate", fqdnFor) |
| + loggedSNIFallback = true |
| + } |
| } |
| id, err := createHealthCheck(ctx, client, zoneID, eip, fqdnFor) |
| if err != nil { |
| @@ -620,45 +637,110 @@ func sameRRSet(have, want r53types.ResourceRecordSet) bool { |
| return true |
| } |
| -// plainConflicts returns the wanted names the zone already holds a PLAIN A |
| -// record for — one with no SetIdentifier — lowercased with the trailing dot. |
| +// conflictedNames returns the wanted names this converger cannot write a |
| +// latency A record at, lowercased with the trailing dot, because an existing |
| +// record there makes the write illegal. |
| +// |
| +// FINDING 2 (review round 1) + FINDING A (round 2, 2026-08-22). Three record |
| +// shapes at a wanted name are all rejected by Route53, and change batches are |
| +// ATOMIC — so the rejection takes the whole batch with it. A single such |
| +// record 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. conflictReason enumerates the |
| +// three; they share one failure mode and therefore one cure. |
| // |
| -// 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 cure is to skip the conflicting NAME entirely — no upsert, no delete — |
| +// and say so loudly, naming what is in the way. Never the alternative of |
| +// deleting the offending record to make room: it is an operator's record |
| +// (a plain record has no SetIdentifier, which is static/operator territory |
| +// under the same rule isManaged enforces; a CNAME or a weighted set is |
| +// somebody's deliberate routing), 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. |
| // |
| -// 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 { |
| +// Scope note: this is a NAME-level refusal, and deliberately so — all three |
| +// shapes make the whole name unwritable. It is not the right shape for a |
| +// KEY-level conflict such as an existing ALIAS latency record at exactly one |
| +// (name, SetIdentifier) we want, which Route53 also refuses to convert in |
| +// place via UPSERT; that one is left open on the report rather than guessed |
| +// at here, because dropping the whole name for it would refuse to steer names |
| +// that are in fact steerable. |
| +func conflictedNames(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 |
| } |
| + reason := conflictReason(rr) |
| + if reason == "" { |
| + 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)) |
| + log.Printf("steering: SKIPPING %s entirely: %s — steering this name needs that record changed by its owner first; every other steered name in this zone is unaffected", |
| + aws.ToString(rr.Name), reason) |
| } |
| return conflicted |
| } |
| +// conflictReason names why an existing record makes its name unwritable by |
| +// this converger, or "" if it does not. Three shapes, all with the SAME |
| +// failure mode — the write is rejected and takes the whole atomic batch with |
| +// it — and therefore all with the same cure: skip the name. |
| +func conflictReason(rr r53types.ResourceRecordSet) string { |
| + setID := aws.ToString(rr.SetIdentifier) |
| + switch { |
| + // A CNAME is EXCLUSIVE in DNS: a name holding one may hold no other |
| + // record type at all. A member steering a hostname that already CNAMEs |
| + // somewhere in the steering zone is not exotic — it is the ordinary |
| + // custom-domain shape the spec itself recommends (CNAME to |
| + // edge.<apps-domain>), pointed at a name inside the same zone. |
| + case rr.Type == r53types.RRTypeCname: |
| + return fmt.Sprintf("the zone already holds a CNAME there (-> %s, TTL %d), and a name holding a CNAME may hold no other record type", |
| + firstValue(rr), aws.ToInt64(rr.TTL)) |
| + |
| + // A plain (simple) A record and a routing-policy record set cannot share |
| + // a name+type. |
| + case rr.Type == r53types.RRTypeA && setID == "": |
| + return fmt.Sprintf("the zone already holds a plain A record there (value %s, TTL %d), and Route53 forbids a name from holding both a simple record and a routing-policy record set", |
| + firstValue(rr), aws.ToInt64(rr.TTL)) |
| + |
| + // One routing POLICY per name+type: a weighted, failover, geo, geo- |
| + // proximity, CIDR or multivalue A set at this name cannot coexist with a |
| + // latency set. Latency records are the one policy we write, and they are |
| + // identified by Region being set. |
| + case rr.Type == r53types.RRTypeA && setID != "" && rr.Region == "": |
| + return fmt.Sprintf("the zone already holds a NON-LATENCY A record set there (set %q, %s), and Route53 allows only one routing policy per name and type", |
| + setID, otherPolicyName(rr)) |
| + } |
| + return "" |
| +} |
| + |
| +// otherPolicyName describes which non-latency routing policy a record set |
| +// uses, for the conflict log — an operator reading it should not have to go |
| +// look the record up to know what they are being told about. |
| +func otherPolicyName(rr r53types.ResourceRecordSet) string { |
| + switch { |
| + case rr.Weight != nil: |
| + return "weighted" |
| + case rr.Failover != "": |
| + return "failover" |
| + case rr.GeoLocation != nil: |
| + return "geolocation" |
| + case rr.GeoProximityLocation != nil: |
| + return "geoproximity" |
| + case rr.CidrRoutingConfig != nil: |
| + return "CIDR" |
| + case aws.ToBool(rr.MultiValueAnswer): |
| + return "multivalue-answer" |
| + } |
| + return "a routing policy this converger does not write" |
| +} |
| + |
| // 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)) |
| @@ -679,10 +761,10 @@ func dropNames(want []desiredRecord, names map[string]bool) []desiredRecord { |
| // |
| // 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 |
| +// BACKSTOP and is deliberately untested — every conflict shape |
| +// (conflictReason) is one Route53 forbids from coexisting with a latency |
| +// record set at that 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) { |
| @@ -700,7 +782,14 @@ func partitionManaged(want []desiredRecord, existing []r53types.ResourceRecordSe |
| // 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 { |
| + // |
| + // NOT when this pass WANTS that key, though: recordChanges is |
| + // about to adopt it, so "leaving it alone" would be a lie about |
| + // the one record here that does get taken over. (The wanted |
| + // check was lost when this loop was split out of recordChanges |
| + // in review round 1; round 2 caught the regression.) |
| + if rr.Type == r53types.RRTypeA && aws.ToString(rr.SetIdentifier) != "" && rr.Region != "" && rr.AliasTarget == nil && |
| + !wanted[recordKey(rr)] { |
| 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)) |
| } |
diff --git a/internal/steering/route53_test.go b/internal/steering/route53_test.go| index 7369652d9..1bbb92a31 100644 |
| --- a/internal/steering/route53_test.go |
| +++ b/internal/steering/route53_test.go |
| @@ -67,6 +67,86 @@ func (f *fakeR53) writes() int { |
| return f.nChangeRecords + f.nCreateCheck + f.nDeleteCheck + f.nChangeTags |
| } |
| +// validateZone enforces the three Route53 rules this converger can violate, |
| +// each of which rejects the WHOLE batch: |
| +// |
| +// 1. a name+type holds either one simple record OR a routing-policy record |
| +// set, never both; |
| +// 2. a name holding a CNAME holds no other record type; |
| +// 3. one routing policy per name+type — a weighted set and a latency set |
| +// cannot share a name. |
| +// |
| +// Modelled on the projected post-batch state, so a batch is judged by what it |
| +// would produce, atomically. |
| +func validateZone(recs []r53types.ResourceRecordSet) error { |
| + type nt struct{ name, typ string } |
| + plain, policy := map[nt]bool{}, map[nt]bool{} |
| + policies := map[nt]map[string]bool{} |
| + names := map[string]map[string]bool{} // name -> set of types |
| + for _, rr := range recs { |
| + name := strings.ToLower(aws.ToString(rr.Name)) |
| + k := nt{name, string(rr.Type)} |
| + if names[name] == nil { |
| + names[name] = map[string]bool{} |
| + } |
| + names[name][string(rr.Type)] = true |
| + if aws.ToString(rr.SetIdentifier) == "" { |
| + plain[k] = true |
| + continue |
| + } |
| + policy[k] = true |
| + if policies[k] == nil { |
| + policies[k] = map[string]bool{} |
| + } |
| + policies[k][fakePolicyKind(rr)] = true |
| + } |
| + for k := range plain { |
| + if policy[k] { |
| + return fmt.Errorf("fake: InvalidChangeBatch: RRSet with DNS name %s and type %s cannot be both a simple record and a routing-policy record set", k.name, k.typ) |
| + } |
| + } |
| + for name, types := range names { |
| + if types["CNAME"] && len(types) > 1 { |
| + return fmt.Errorf("fake: InvalidChangeBatch: RRSet with DNS name %s has a CNAME, which cannot coexist with any other record type", name) |
| + } |
| + } |
| + for k, kinds := range policies { |
| + if len(kinds) > 1 { |
| + return fmt.Errorf("fake: InvalidChangeBatch: RRSet with DNS name %s and type %s mixes routing policies (%v) — only one policy per name and type", k.name, k.typ, sortedKeys(kinds)) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func fakePolicyKind(rr r53types.ResourceRecordSet) string { |
| + switch { |
| + case rr.Region != "": |
| + return "latency" |
| + case rr.Weight != nil: |
| + return "weighted" |
| + case rr.Failover != "": |
| + return "failover" |
| + case rr.GeoLocation != nil: |
| + return "geolocation" |
| + case rr.GeoProximityLocation != nil: |
| + return "geoproximity" |
| + case rr.CidrRoutingConfig != nil: |
| + return "cidr" |
| + case aws.ToBool(rr.MultiValueAnswer): |
| + return "multivalue" |
| + } |
| + return "unknown" |
| +} |
| + |
| +func sortedKeys(m map[string]bool) []string { |
| + out := make([]string, 0, len(m)) |
| + for k := range m { |
| + out = append(out, k) |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| func rrKey(rr r53types.ResourceRecordSet) string { |
| return strings.ToLower(aws.ToString(rr.Name)) + "|" + string(rr.Type) + "|" + aws.ToString(rr.SetIdentifier) |
| } |
| @@ -143,22 +223,8 @@ func (f *fakeR53) ChangeResourceRecordSets(ctx context.Context, in *route53.Chan |
| } |
| } |
| - // Route53's rule: for one (name, type), either a single simple record OR |
| - // a set of routing-policy records — never both. |
| - type nt struct{ name, typ string } |
| - plain, policy := map[nt]bool{}, map[nt]bool{} |
| - for _, rr := range projected { |
| - k := nt{strings.ToLower(aws.ToString(rr.Name)), string(rr.Type)} |
| - if aws.ToString(rr.SetIdentifier) == "" { |
| - plain[k] = true |
| - } else { |
| - policy[k] = true |
| - } |
| - } |
| - for k := range plain { |
| - if policy[k] { |
| - return nil, fmt.Errorf("fake: InvalidChangeBatch: RRSet with DNS name %s and type %s cannot be both a simple record and a routing-policy record set", k.name, k.typ) |
| - } |
| + if err := validateZone(projected); err != nil { |
| + return nil, err |
| } |
| f.submitted = append(f.submitted, in.ChangeBatch.Changes...) |
| @@ -619,39 +685,179 @@ func TestConvergeStaticRecordsUntouched(t *testing.T) { |
| } |
| } |
| -// TestFakeRejectsPlainVsPolicyConflict proves the fake's new refusal is real, |
| -// so the skip above is a fix and not a tautology: submitting the batch the |
| -// converger used to submit does fail, atomically, taking an unrelated and |
| -// perfectly legal change in the same batch down with it. |
| -func TestFakeRejectsPlainVsPolicyConflict(t *testing.T) { |
| +// TestFakeRejectsConflictingBatches proves the fake's refusals are real, so |
| +// the skips are fixes and not tautologies: submitting the batch the converger |
| +// used to submit does fail, atomically, taking an unrelated and perfectly |
| +// legal change in the same batch down with it. One row per conflict shape. |
| +func TestFakeRejectsConflictingBatches(t *testing.T) { |
| + tests := []struct { |
| + name string |
| + seeded r53types.ResourceRecordSet |
| + wantErr string |
| + }{ |
| + { |
| + name: "plain A", |
| + seeded: staticA("app.example.com.", "9.9.9.9"), |
| + wantErr: "cannot be both a simple record and a routing-policy record set", |
| + }, |
| + { |
| + name: "CNAME", |
| + seeded: r53types.ResourceRecordSet{ |
| + Name: aws.String("app.example.com."), Type: r53types.RRTypeCname, TTL: aws.Int64(300), |
| + ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("elsewhere.example.net.")}}, |
| + }, |
| + wantErr: "cannot coexist with any other record type", |
| + }, |
| + { |
| + name: "weighted A set", |
| + seeded: r53types.ResourceRecordSet{ |
| + Name: aws.String("app.example.com."), Type: r53types.RRTypeA, |
| + SetIdentifier: aws.String("legacy"), Weight: aws.Int64(10), TTL: aws.Int64(60), |
| + ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("8.8.8.8")}}, |
| + }, |
| + wantErr: "mixes routing policies", |
| + }, |
| + } |
| + for _, tt := range tests { |
| + t.Run(tt.name, func(t *testing.T) { |
| + f := newFake() |
| + f.records = []r53types.ResourceRecordSet{tt.seeded} |
| + |
| + latency := r53types.ResourceRecordSet{ |
| + Name: aws.String("app.example.com."), Type: r53types.RRTypeA, |
| + SetIdentifier: aws.String("dublin"), Region: "eu-west-1", TTL: aws.Int64(60), |
| + ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, |
| + } |
| + innocent := r53types.ResourceRecordSet{ |
| + Name: aws.String("edge.example.com."), Type: r53types.RRTypeA, |
| + SetIdentifier: aws.String("dublin"), Region: "eu-west-1", TTL: aws.Int64(60), |
| + ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, |
| + } |
| + _, err := f.ChangeResourceRecordSets(context.Background(), &route53.ChangeResourceRecordSetsInput{ |
| + HostedZoneId: aws.String(testZone), |
| + ChangeBatch: &r53types.ChangeBatch{Changes: []r53types.Change{ |
| + {Action: r53types.ChangeActionUpsert, ResourceRecordSet: &innocent}, |
| + {Action: r53types.ChangeActionUpsert, ResourceRecordSet: &latency}, |
| + }}, |
| + }) |
| + if err == nil { |
| + t.Fatalf("the fake accepted the batch — it is more permissive than Route53") |
| + } |
| + if !strings.Contains(err.Error(), "InvalidChangeBatch") || !strings.Contains(err.Error(), tt.wantErr) { |
| + t.Errorf("wrong error: %v", err) |
| + } |
| + // atomic: the innocent change did NOT land either |
| + if len(f.records) != 1 { |
| + t.Fatalf("the batch was not atomic: %v", recordNames(f.records)) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestConvergeSkipsConflictingNames is FINDING A: the plain-A wedge has two |
| +// siblings with the identical failure mode. Each must be skipped by name, and |
| +// each must leave the OTHER steered names converging normally. |
| +func TestConvergeSkipsConflictingNames(t *testing.T) { |
| + tests := []struct { |
| + name string |
| + seeded r53types.ResourceRecordSet |
| + wantLogged string |
| + }{ |
| + { |
| + name: "a CNAME at a steered name", |
| + seeded: r53types.ResourceRecordSet{ |
| + Name: aws.String("app.example.com."), Type: r53types.RRTypeCname, TTL: aws.Int64(300), |
| + ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("elsewhere.example.net.")}}, |
| + }, |
| + wantLogged: "a name holding a CNAME may hold no other record type", |
| + }, |
| + { |
| + name: "a weighted A set at a steered name", |
| + seeded: r53types.ResourceRecordSet{ |
| + Name: aws.String("app.example.com."), Type: r53types.RRTypeA, |
| + SetIdentifier: aws.String("legacy"), Weight: aws.Int64(10), TTL: aws.Int64(60), |
| + ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("8.8.8.8")}}, |
| + }, |
| + wantLogged: "only one routing policy per name and type", |
| + }, |
| + { |
| + name: "a failover A set at a steered name", |
| + seeded: r53types.ResourceRecordSet{ |
| + Name: aws.String("app.example.com."), Type: r53types.RRTypeA, |
| + SetIdentifier: aws.String("primary"), Failover: r53types.ResourceRecordSetFailoverPrimary, |
| + TTL: aws.Int64(60), |
| + ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("8.8.8.8")}}, |
| + }, |
| + wantLogged: "only one routing policy per name and type", |
| + }, |
| + } |
| + for _, tt := range tests { |
| + t.Run(tt.name, func(t *testing.T) { |
| + f := newFake() |
| + f.records = []r53types.ResourceRecordSet{tt.seeded} |
| + seededKey := rrKey(tt.seeded) |
| + |
| + buf := captureLog(t) |
| + want := append([]desiredRecord{dublinRec}, hostRecs("app.example.com.", dublinRec)...) |
| + mustConverge(t, f, want) |
| + |
| + // the conflicted name is skipped ENTIRELY — no upsert, no delete |
| + for _, c := range f.submitted { |
| + if strings.EqualFold(aws.ToString(c.ResourceRecordSet.Name), "app.example.com.") { |
| + t.Errorf("submitted a change for the conflicted name: %s %s", c.Action, rrKey(*c.ResourceRecordSet)) |
| + } |
| + } |
| + // the operator's record is untouched |
| + var found bool |
| + for _, rr := range f.records { |
| + if rrKey(rr) == seededKey { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Errorf("the operator's record disappeared; zone = %v", recordNames(f.records)) |
| + } |
| + // the log names the conflict and why |
| + logged := buf.String() |
| + if !strings.Contains(logged, "SKIPPING app.example.com.") { |
| + t.Errorf("the conflict was not named; log = %q", logged) |
| + } |
| + if !strings.Contains(logged, tt.wantLogged) { |
| + t.Errorf("the log did not explain the conflict (%q); log = %q", tt.wantLogged, logged) |
| + } |
| + // ...and the OTHER name still converges in the same pass |
| + edgeRR := find(t, f, "edge.example.com.", "dublin") |
| + if aws.ToString(edgeRR.ResourceRecords[0].Value) != "1.2.3.4" { |
| + t.Errorf("the unconflicted name did not converge: %v", edgeRR.ResourceRecords) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestConvergeSNIDowngradeIsLogged: when the synthetic edge.<apps-domain> |
| +// name is itself dropped for a conflict, every health check created |
| +// afterwards silently falls back to a member hostname for SNI. That |
| +// downgrade must be visible. |
| +func TestConvergeSNIDowngradeIsLogged(t *testing.T) { |
| f := newFake() |
| - f.records = []r53types.ResourceRecordSet{staticA("app.example.com.", "9.9.9.9")} |
| + f.records = []r53types.ResourceRecordSet{staticA("edge.example.com.", "9.9.9.9")} |
| + |
| + buf := captureLog(t) |
| + want := append([]desiredRecord{dublinRec}, hostRecs("app.example.com.", dublinRec)...) |
| + mustConverge(t, f, want) |
| - latency := r53types.ResourceRecordSet{ |
| - Name: aws.String("app.example.com."), Type: r53types.RRTypeA, |
| - SetIdentifier: aws.String("dublin"), Region: "eu-west-1", TTL: aws.Int64(60), |
| - ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, |
| + if len(f.createdChecks) != 1 { |
| + t.Fatalf("want 1 check, got %d", len(f.createdChecks)) |
| } |
| - innocent := r53types.ResourceRecordSet{ |
| - Name: aws.String("edge.example.com."), Type: r53types.RRTypeA, |
| - SetIdentifier: aws.String("dublin"), Region: "eu-west-1", TTL: aws.Int64(60), |
| - ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, |
| - } |
| - _, err := f.ChangeResourceRecordSets(context.Background(), &route53.ChangeResourceRecordSetsInput{ |
| - HostedZoneId: aws.String(testZone), |
| - ChangeBatch: &r53types.ChangeBatch{Changes: []r53types.Change{ |
| - {Action: r53types.ChangeActionUpsert, ResourceRecordSet: &innocent}, |
| - {Action: r53types.ChangeActionUpsert, ResourceRecordSet: &latency}, |
| - }}, |
| - }) |
| - if err == nil { |
| - t.Fatalf("the fake accepted a plain-plus-latency name — it is more permissive than Route53") |
| + if got := aws.ToString(f.createdChecks[0].HealthCheckConfig.FullyQualifiedDomainName); got != "app.example.com" { |
| + t.Fatalf("SNI = %q, want the member hostname fallback", got) |
| } |
| - if !strings.Contains(err.Error(), "InvalidChangeBatch") { |
| - t.Errorf("wrong error: %v", err) |
| + logged := buf.String() |
| + if !strings.Contains(logged, "fall back to a member hostname") { |
| + t.Errorf("the SNI downgrade was silent; log = %q", logged) |
| } |
| - if len(f.records) != 1 { |
| - t.Fatalf("the batch was not atomic: %v", recordNames(f.records)) |
| + if !strings.Contains(logged, "SKIPPING edge.example.com.") { |
| + t.Errorf("the cause was not logged alongside it; log = %q", logged) |
| } |
| } |
| @@ -927,6 +1133,37 @@ func TestConvergeAdoptsForeignLatencyRecordUnderOurKey(t *testing.T) { |
| if !strings.Contains(buf.String(), "adopting") { |
| t.Errorf("the takeover was silent; log = %q", buf.String()) |
| } |
| + // ...and it must NOT also claim to be leaving that same record alone. |
| + // The two lines describe opposite fates for one record; only one of them |
| + // is true. (Regression: the "leaving alone" log lost its wanted-key |
| + // guard when the loop was split out of recordChanges in review round 1.) |
| + if strings.Contains(buf.String(), "leaving latency record") { |
| + t.Errorf("logged BOTH adoption and 'leaving alone' for the same record; log = %q", buf.String()) |
| + } |
| +} |
| + |
| +// TestConvergeLeavingAloneLogNamesOnlyUnwantedKeys: the "leaving alone" line |
| +// is still owed for a foreign latency record this pass genuinely does not |
| +// touch — the FINDING B fix must not silence it everywhere. |
| +func TestConvergeLeavingAloneLogNamesOnlyUnwantedKeys(t *testing.T) { |
| + f := newFake() |
| + f.records = []r53types.ResourceRecordSet{ |
| + { // a foreign latency record at a name/identifier we do NOT want |
| + Name: aws.String("legacy.example.com."), Type: r53types.RRTypeA, |
| + SetIdentifier: aws.String("dublin"), Region: "eu-west-1", TTL: aws.Int64(60), |
| + HealthCheckId: aws.String("hc-someone-else"), |
| + ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("7.7.7.7")}}, |
| + }, |
| + } |
| + buf := captureLog(t) |
| + mustConverge(t, f, []desiredRecord{dublinRec}) |
| + |
| + if !strings.Contains(buf.String(), "leaving latency record legacy.example.com.") { |
| + t.Errorf("the untouched foreign record was not reported; log = %q", buf.String()) |
| + } |
| + if len(f.records) != 2 { |
| + t.Errorf("the foreign record was touched; zone = %v", recordNames(f.records)) |
| + } |
| } |
| // TestIsManagedGates pins the deletion boundary GATE BY GATE. |