carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package steering
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "reflect"
8 "sort"
9 "strings"
10 "testing"
11
12 "github.com/aws/aws-sdk-go-v2/aws"
13 "github.com/aws/aws-sdk-go-v2/service/route53"
14 r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
15)
16
17// ---------------------------------------------------------------------------
18// the fake
19// ---------------------------------------------------------------------------
20
21// fakeR53 is an in-memory Route53 that records every call and APPLIES the
22// changes it is handed, so a second converge against the same fake sees the
23// state the first one left. No network, ever — the whole point of route53API
24// being an interface.
25//
26// It deliberately reproduces the real API's refusals that matter to this
27// converger: DELETE of a record that is not present errors (Route53's
28// InvalidChangeBatch), a name+type may not hold BOTH a plain record and a
29// routing-policy record set (also InvalidChangeBatch, and it takes the whole
30// atomic batch with it), ListTagsForResources refuses more than 10 ids, and
31// DeleteHealthCheck of a check still referenced by a record errors
32// (HealthCheckInUse). A fake that is more permissive than the service would
33// let a bug through — the plain-vs-policy rule is in here precisely because
34// an earlier version of this file was more permissive and enshrined a zone
35// state Route53 forbids.
36type fakeR53 struct {
37 records []r53types.ResourceRecordSet
38 checks []r53types.HealthCheck
39 tags map[string][]r53types.Tag // health check id -> tags
40
41 pageSize int // 0 = one page for everything
42 nextID int
43
44 // call counters — the idempotency assertions live on these
45 nListRecords int
46 nChangeRecords int
47 nListChecks int
48 nListTags int
49 nCreateCheck int
50 nDeleteCheck int
51 nChangeTags int
52
53 submitted []r53types.Change // every change submitted, in order
54 createdChecks []*route53.CreateHealthCheckInput
55 deletedChecks []string
56 taggedIDs []string
57
58 failChange error // ChangeResourceRecordSets returns this
59 failTags error // ChangeTagsForResource returns this
60}
61
62func newFake() *fakeR53 { return &fakeR53{tags: map[string][]r53types.Tag{}} }
63
64// writes is the total number of state-changing calls — the number an
65// idempotent second pass must leave at zero.
66func (f *fakeR53) writes() int {
67 return f.nChangeRecords + f.nCreateCheck + f.nDeleteCheck + f.nChangeTags
68}
69
70// validateZone enforces the three Route53 rules this converger can violate,
71// each of which rejects the WHOLE batch:
72//
73// 1. a name+type holds either one simple record OR a routing-policy record
74// set, never both;
75// 2. a name holding a CNAME holds no other record type;
76// 3. one routing policy per name+type — a weighted set and a latency set
77// cannot share a name.
78//
79// Modelled on the projected post-batch state, so a batch is judged by what it
80// would produce, atomically.
81func validateZone(recs []r53types.ResourceRecordSet) error {
82 type nt struct{ name, typ string }
83 plain, policy := map[nt]bool{}, map[nt]bool{}
84 policies := map[nt]map[string]bool{}
85 names := map[string]map[string]bool{} // name -> set of types
86 for _, rr := range recs {
87 name := strings.ToLower(aws.ToString(rr.Name))
88 k := nt{name, string(rr.Type)}
89 if names[name] == nil {
90 names[name] = map[string]bool{}
91 }
92 names[name][string(rr.Type)] = true
93 if aws.ToString(rr.SetIdentifier) == "" {
94 plain[k] = true
95 continue
96 }
97 policy[k] = true
98 if policies[k] == nil {
99 policies[k] = map[string]bool{}
100 }
101 policies[k][fakePolicyKind(rr)] = true
102 }
103 for k := range plain {
104 if policy[k] {
105 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)
106 }
107 }
108 for name, types := range names {
109 if types["CNAME"] && len(types) > 1 {
110 return fmt.Errorf("fake: InvalidChangeBatch: RRSet with DNS name %s has a CNAME, which cannot coexist with any other record type", name)
111 }
112 }
113 for k, kinds := range policies {
114 if len(kinds) > 1 {
115 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))
116 }
117 }
118 return nil
119}
120
121func fakePolicyKind(rr r53types.ResourceRecordSet) string {
122 switch {
123 case rr.Region != "":
124 return "latency"
125 case rr.Weight != nil:
126 return "weighted"
127 case rr.Failover != "":
128 return "failover"
129 case rr.GeoLocation != nil:
130 return "geolocation"
131 case rr.GeoProximityLocation != nil:
132 return "geoproximity"
133 case rr.CidrRoutingConfig != nil:
134 return "cidr"
135 case aws.ToBool(rr.MultiValueAnswer):
136 return "multivalue"
137 }
138 return "unknown"
139}
140
141func sortedKeys(m map[string]bool) []string {
142 out := make([]string, 0, len(m))
143 for k := range m {
144 out = append(out, k)
145 }
146 sort.Strings(out)
147 return out
148}
149
150func rrKey(rr r53types.ResourceRecordSet) string {
151 return strings.ToLower(aws.ToString(rr.Name)) + "|" + string(rr.Type) + "|" + aws.ToString(rr.SetIdentifier)
152}
153
154func (f *fakeR53) ListResourceRecordSets(ctx context.Context, in *route53.ListResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) {
155 f.nListRecords++
156 if aws.ToString(in.HostedZoneId) == "" {
157 return nil, errors.New("fake: no hosted zone id")
158 }
159 start := 0
160 if in.StartRecordName != nil {
161 for i, rr := range f.records {
162 if strings.EqualFold(aws.ToString(rr.Name), aws.ToString(in.StartRecordName)) &&
163 rr.Type == in.StartRecordType &&
164 aws.ToString(rr.SetIdentifier) == aws.ToString(in.StartRecordIdentifier) {
165 start = i
166 break
167 }
168 }
169 }
170 end := len(f.records)
171 if f.pageSize > 0 && start+f.pageSize < end {
172 end = start + f.pageSize
173 }
174 out := &route53.ListResourceRecordSetsOutput{
175 ResourceRecordSets: append([]r53types.ResourceRecordSet(nil), f.records[start:end]...),
176 }
177 if end < len(f.records) {
178 next := f.records[end]
179 out.IsTruncated = true
180 out.NextRecordName = next.Name
181 out.NextRecordType = next.Type
182 out.NextRecordIdentifier = next.SetIdentifier
183 }
184 return out, nil
185}
186
187func (f *fakeR53) ChangeResourceRecordSets(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, _ ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {
188 f.nChangeRecords++
189 if f.failChange != nil {
190 return nil, f.failChange
191 }
192 if in.ChangeBatch == nil || len(in.ChangeBatch.Changes) == 0 {
193 return nil, errors.New("fake: empty change batch")
194 }
195
196 // A batch is ATOMIC: project it onto a copy, validate the result, and
197 // commit only if the whole thing is legal.
198 projected := append([]r53types.ResourceRecordSet(nil), f.records...)
199 for _, c := range in.ChangeBatch.Changes {
200 rr := *c.ResourceRecordSet
201 key := rrKey(rr)
202 idx := -1
203 for i, have := range projected {
204 if rrKey(have) == key {
205 idx = i
206 break
207 }
208 }
209 switch c.Action {
210 case r53types.ChangeActionUpsert:
211 if idx >= 0 {
212 projected[idx] = rr
213 } else {
214 projected = append(projected, rr)
215 }
216 case r53types.ChangeActionDelete:
217 if idx < 0 {
218 return nil, fmt.Errorf("fake: InvalidChangeBatch: no such record %s", key)
219 }
220 projected = append(projected[:idx], projected[idx+1:]...)
221 default:
222 return nil, fmt.Errorf("fake: unsupported action %q", c.Action)
223 }
224 }
225
226 if err := validateZone(projected); err != nil {
227 return nil, err
228 }
229
230 f.submitted = append(f.submitted, in.ChangeBatch.Changes...)
231 f.records = projected
232 sort.SliceStable(f.records, func(i, j int) bool { return rrKey(f.records[i]) < rrKey(f.records[j]) })
233 return &route53.ChangeResourceRecordSetsOutput{}, nil
234}
235
236func (f *fakeR53) ListHealthChecks(ctx context.Context, in *route53.ListHealthChecksInput, _ ...func(*route53.Options)) (*route53.ListHealthChecksOutput, error) {
237 f.nListChecks++
238 start := 0
239 if in.Marker != nil {
240 for i, hc := range f.checks {
241 if aws.ToString(hc.Id) == aws.ToString(in.Marker) {
242 start = i
243 break
244 }
245 }
246 }
247 end := len(f.checks)
248 if f.pageSize > 0 && start+f.pageSize < end {
249 end = start + f.pageSize
250 }
251 out := &route53.ListHealthChecksOutput{
252 HealthChecks: append([]r53types.HealthCheck(nil), f.checks[start:end]...),
253 }
254 if end < len(f.checks) {
255 out.IsTruncated = true
256 out.NextMarker = f.checks[end].Id
257 }
258 return out, nil
259}
260
261func (f *fakeR53) ListTagsForResources(ctx context.Context, in *route53.ListTagsForResourcesInput, _ ...func(*route53.Options)) (*route53.ListTagsForResourcesOutput, error) {
262 f.nListTags++
263 if in.ResourceType != r53types.TagResourceTypeHealthcheck {
264 return nil, fmt.Errorf("fake: unexpected resource type %q", in.ResourceType)
265 }
266 if len(in.ResourceIds) == 0 || len(in.ResourceIds) > 10 {
267 return nil, fmt.Errorf("fake: ListTagsForResources takes 1-10 ids, got %d", len(in.ResourceIds))
268 }
269 out := &route53.ListTagsForResourcesOutput{}
270 for _, id := range in.ResourceIds {
271 out.ResourceTagSets = append(out.ResourceTagSets, r53types.ResourceTagSet{
272 ResourceId: aws.String(id),
273 ResourceType: r53types.TagResourceTypeHealthcheck,
274 Tags: f.tags[id],
275 })
276 }
277 return out, nil
278}
279
280func (f *fakeR53) CreateHealthCheck(ctx context.Context, in *route53.CreateHealthCheckInput, _ ...func(*route53.Options)) (*route53.CreateHealthCheckOutput, error) {
281 f.nCreateCheck++
282 f.createdChecks = append(f.createdChecks, in)
283 f.nextID++
284 id := fmt.Sprintf("hc-%d", f.nextID)
285 hc := r53types.HealthCheck{
286 Id: aws.String(id),
287 CallerReference: in.CallerReference,
288 HealthCheckConfig: in.HealthCheckConfig,
289 }
290 f.checks = append(f.checks, hc)
291 return &route53.CreateHealthCheckOutput{HealthCheck: &hc, Location: aws.String("https://route53/" + id)}, nil
292}
293
294func (f *fakeR53) DeleteHealthCheck(ctx context.Context, in *route53.DeleteHealthCheckInput, _ ...func(*route53.Options)) (*route53.DeleteHealthCheckOutput, error) {
295 f.nDeleteCheck++
296 id := aws.ToString(in.HealthCheckId)
297 for _, rr := range f.records {
298 if aws.ToString(rr.HealthCheckId) == id {
299 return nil, fmt.Errorf("fake: HealthCheckInUse: %s", id)
300 }
301 }
302 f.deletedChecks = append(f.deletedChecks, id)
303 for i, hc := range f.checks {
304 if aws.ToString(hc.Id) == id {
305 f.checks = append(f.checks[:i], f.checks[i+1:]...)
306 break
307 }
308 }
309 delete(f.tags, id)
310 return &route53.DeleteHealthCheckOutput{}, nil
311}
312
313func (f *fakeR53) ChangeTagsForResource(ctx context.Context, in *route53.ChangeTagsForResourceInput, _ ...func(*route53.Options)) (*route53.ChangeTagsForResourceOutput, error) {
314 f.nChangeTags++
315 if f.failTags != nil {
316 return nil, f.failTags
317 }
318 id := aws.ToString(in.ResourceId)
319 f.taggedIDs = append(f.taggedIDs, id)
320 f.tags[id] = append(f.tags[id], in.AddTags...)
321 return &route53.ChangeTagsForResourceOutput{}, nil
322}
323
324// ---------------------------------------------------------------------------
325// fixtures + helpers
326// ---------------------------------------------------------------------------
327
328const testZone = "Z0STEERINGTEST"
329
330var (
331 dublinRec = desiredRecord{Name: "edge.example.com.", EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1"}
332 oregonRec = desiredRecord{Name: "edge.example.com.", EIP: "5.6.7.8", Box: "oregon", Region: "us-west-2"}
333)
334
335// hostRecs mints the per-host half of a desired set: one record per edge.
336func hostRecs(name string, edges ...desiredRecord) []desiredRecord {
337 out := make([]desiredRecord, 0, len(edges))
338 for _, e := range edges {
339 out = append(out, desiredRecord{Name: name, EIP: e.EIP, Box: e.Box, Region: e.Region})
340 }
341 return out
342}
343
344func staticA(name, ip string) r53types.ResourceRecordSet {
345 return r53types.ResourceRecordSet{
346 Name: aws.String(name),
347 Type: r53types.RRTypeA,
348 TTL: aws.Int64(300),
349 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(ip)}},
350 }
351}
352
353func recordNames(recs []r53types.ResourceRecordSet) []string {
354 out := make([]string, 0, len(recs))
355 for _, rr := range recs {
356 out = append(out, rrKey(rr))
357 }
358 sort.Strings(out)
359 return out
360}
361
362func find(t *testing.T, f *fakeR53, name, setID string) r53types.ResourceRecordSet {
363 t.Helper()
364 for _, rr := range f.records {
365 if strings.EqualFold(aws.ToString(rr.Name), name) && aws.ToString(rr.SetIdentifier) == setID {
366 return rr
367 }
368 }
369 t.Fatalf("no record %s / setid %q in zone; have %v", name, setID, recordNames(f.records))
370 return r53types.ResourceRecordSet{}
371}
372
373func mustConverge(t *testing.T, f *fakeR53, want []desiredRecord) {
374 t.Helper()
375 if err := converge(context.Background(), f, testZone, want); err != nil {
376 t.Fatalf("converge: %v", err)
377 }
378}
379
380// ---------------------------------------------------------------------------
381// tests
382// ---------------------------------------------------------------------------
383
384// TestConvergeCreateFromEmpty is the brief's create-from-empty case: an
385// empty zone, two armed edges, one steered host.
386func TestConvergeCreateFromEmpty(t *testing.T) {
387 f := newFake()
388 want := append([]desiredRecord{dublinRec, oregonRec}, hostRecs("app.example.com.", dublinRec, oregonRec)...)
389 mustConverge(t, f, want)
390
391 if len(f.records) != 4 {
392 t.Fatalf("want 4 records, got %d: %v", len(f.records), recordNames(f.records))
393 }
394 if f.nCreateCheck != 2 {
395 t.Fatalf("want one health check per distinct EIP (2), got %d", f.nCreateCheck)
396 }
397 if f.nChangeTags != 2 {
398 t.Fatalf("want every created check tagged (2), got %d", f.nChangeTags)
399 }
400
401 // health check shape
402 byIP := map[string]*route53.CreateHealthCheckInput{}
403 for _, in := range f.createdChecks {
404 byIP[aws.ToString(in.HealthCheckConfig.IPAddress)] = in
405 }
406 for _, ip := range []string{"1.2.3.4", "5.6.7.8"} {
407 in, ok := byIP[ip]
408 if !ok {
409 t.Fatalf("no health check created for %s", ip)
410 }
411 cfg := in.HealthCheckConfig
412 if cfg.Type != r53types.HealthCheckTypeHttps {
413 t.Errorf("%s: type = %q, want HTTPS", ip, cfg.Type)
414 }
415 if aws.ToInt32(cfg.Port) != 443 {
416 t.Errorf("%s: port = %d, want 443", ip, aws.ToInt32(cfg.Port))
417 }
418 if !aws.ToBool(cfg.EnableSNI) {
419 t.Errorf("%s: EnableSNI false, want true (the SNI name is the whole point)", ip)
420 }
421 // FQDN = the steering hostname, dot-trimmed (it is an SNI/Host value,
422 // not a Route53 record name).
423 if got := aws.ToString(cfg.FullyQualifiedDomainName); got != "edge.example.com" {
424 t.Errorf("%s: FQDN = %q, want the steering hostname edge.example.com", ip, got)
425 }
426 if !strings.HasPrefix(aws.ToString(in.CallerReference), "carlos-steering-") {
427 t.Errorf("%s: CallerReference = %q, want a carlos-steering- prefix", ip, aws.ToString(in.CallerReference))
428 }
429 if n := len(aws.ToString(in.CallerReference)); n > 64 {
430 t.Errorf("%s: CallerReference is %d chars, Route53 caps it at 64", ip, n)
431 }
432 }
433
434 // every created check carries the zone-scoped tag
435 for id, tags := range f.tags {
436 var found bool
437 for _, tg := range tags {
438 if aws.ToString(tg.Key) == steeringTagKey && aws.ToString(tg.Value) == testZone {
439 found = true
440 }
441 }
442 if !found {
443 t.Errorf("check %s not tagged %s=%s", id, steeringTagKey, testZone)
444 }
445 }
446
447 // record shape
448 rr := find(t, f, "app.example.com.", "dublin")
449 if rr.Type != r53types.RRTypeA {
450 t.Errorf("type = %q, want A", rr.Type)
451 }
452 if aws.ToInt64(rr.TTL) != 60 {
453 t.Errorf("TTL = %d, want 60", aws.ToInt64(rr.TTL))
454 }
455 if string(rr.Region) != "eu-west-1" {
456 t.Errorf("Region = %q, want eu-west-1", rr.Region)
457 }
458 if rr.AliasTarget != nil {
459 t.Errorf("AliasTarget set; these are plain latency records to EIPs")
460 }
461 if len(rr.ResourceRecords) != 1 || aws.ToString(rr.ResourceRecords[0].Value) != "1.2.3.4" {
462 t.Errorf("value = %v, want 1.2.3.4", rr.ResourceRecords)
463 }
464 if aws.ToString(rr.HealthCheckId) == "" {
465 t.Errorf("record not bound to a health check")
466 }
467 // the record for the same EIP under a different name shares the check
468 edgeRR := find(t, f, "edge.example.com.", "dublin")
469 if aws.ToString(edgeRR.HealthCheckId) != aws.ToString(rr.HealthCheckId) {
470 t.Errorf("one check per EIP violated: %q vs %q", aws.ToString(edgeRR.HealthCheckId), aws.ToString(rr.HealthCheckId))
471 }
472}
473
474// TestConvergeIdempotentZeroWrites is the brief's headline safety property:
475// a second pass with an identical want makes NO write calls at all.
476func TestConvergeIdempotentZeroWrites(t *testing.T) {
477 f := newFake()
478 want := append([]desiredRecord{dublinRec, oregonRec}, hostRecs("app.example.com.", dublinRec, oregonRec)...)
479 mustConverge(t, f, want)
480
481 before := f.writes()
482 if before == 0 {
483 t.Fatalf("first pass made no writes; the test proves nothing")
484 }
485 mustConverge(t, f, want)
486 if got := f.writes() - before; got != 0 {
487 t.Fatalf("second pass made %d write calls, want 0 (records=%d creates=%d deletes=%d tags=%d)",
488 got, f.nChangeRecords, f.nCreateCheck, f.nDeleteCheck, f.nChangeTags)
489 }
490 // a third pass, for good measure — no slow drift
491 mustConverge(t, f, want)
492 if got := f.writes() - before; got != 0 {
493 t.Fatalf("third pass made %d write calls, want 0", got)
494 }
495}
496
497// TestConvergeAddEdge: a second edge arms. Only the new records are written;
498// the existing ones are left alone.
499func TestConvergeAddEdge(t *testing.T) {
500 f := newFake()
501 one := append([]desiredRecord{dublinRec}, hostRecs("app.example.com.", dublinRec)...)
502 mustConverge(t, f, one)
503 base := f.writes()
504 f.submitted = nil
505
506 two := append([]desiredRecord{dublinRec, oregonRec}, hostRecs("app.example.com.", dublinRec, oregonRec)...)
507 mustConverge(t, f, two)
508
509 if f.nCreateCheck != 2 {
510 t.Fatalf("want exactly one NEW check (2 total), got %d total", f.nCreateCheck)
511 }
512 if f.nDeleteCheck != 0 {
513 t.Fatalf("adding an edge deleted %d checks", f.nDeleteCheck)
514 }
515 if len(f.submitted) != 2 {
516 t.Fatalf("want 2 changes (the new edge's records only), got %d: %v", len(f.submitted), f.submitted)
517 }
518 for _, c := range f.submitted {
519 if c.Action != r53types.ChangeActionUpsert {
520 t.Errorf("adding an edge submitted a %s", c.Action)
521 }
522 if aws.ToString(c.ResourceRecordSet.SetIdentifier) != "oregon" {
523 t.Errorf("rewrote an untouched record: %s", rrKey(*c.ResourceRecordSet))
524 }
525 }
526 if len(f.records) != 4 {
527 t.Fatalf("want 4 records, got %v", recordNames(f.records))
528 }
529 _ = base
530}
531
532// TestConvergeRemoveEdge: an edge disarms. Its records go, and the health
533// check it alone used is GC'd; the surviving edge's check is retained.
534func TestConvergeRemoveEdge(t *testing.T) {
535 f := newFake()
536 two := append([]desiredRecord{dublinRec, oregonRec}, hostRecs("app.example.com.", dublinRec, oregonRec)...)
537 mustConverge(t, f, two)
538 oregonCheck := aws.ToString(find(t, f, "edge.example.com.", "oregon").HealthCheckId)
539 dublinCheck := aws.ToString(find(t, f, "edge.example.com.", "dublin").HealthCheckId)
540 f.submitted = nil
541
542 one := append([]desiredRecord{dublinRec}, hostRecs("app.example.com.", dublinRec)...)
543 mustConverge(t, f, one)
544
545 if len(f.records) != 2 {
546 t.Fatalf("want 2 records left, got %v", recordNames(f.records))
547 }
548 for _, rr := range f.records {
549 if aws.ToString(rr.SetIdentifier) == "oregon" {
550 t.Errorf("oregon record survived: %s", rrKey(rr))
551 }
552 }
553 for _, c := range f.submitted {
554 if c.Action != r53types.ChangeActionDelete {
555 t.Errorf("removing an edge submitted a %s for %s", c.Action, rrKey(*c.ResourceRecordSet))
556 }
557 }
558 if len(f.deletedChecks) != 1 || f.deletedChecks[0] != oregonCheck {
559 t.Fatalf("want the orphaned check %s GC'd, got %v", oregonCheck, f.deletedChecks)
560 }
561 // the surviving check is untouched and still bound
562 if got := aws.ToString(find(t, f, "app.example.com.", "dublin").HealthCheckId); got != dublinCheck {
563 t.Errorf("dublin's check changed: %q -> %q", dublinCheck, got)
564 }
565}
566
567// TestConvergeRemoveSteering: a host stops being steered. Its records go,
568// but the checks stay — another host (and the steering hostname) still uses
569// the same EIPs.
570func TestConvergeRemoveSteering(t *testing.T) {
571 f := newFake()
572 both := append([]desiredRecord{dublinRec, oregonRec}, hostRecs("app.example.com.", dublinRec, oregonRec)...)
573 both = append(both, hostRecs("other.example.com.", dublinRec, oregonRec)...)
574 mustConverge(t, f, both)
575 if len(f.records) != 6 {
576 t.Fatalf("setup: want 6 records, got %v", recordNames(f.records))
577 }
578 checksBefore := len(f.checks)
579 f.submitted = nil
580
581 one := append([]desiredRecord{dublinRec, oregonRec}, hostRecs("other.example.com.", dublinRec, oregonRec)...)
582 mustConverge(t, f, one)
583
584 if len(f.records) != 4 {
585 t.Fatalf("want 4 records left, got %v", recordNames(f.records))
586 }
587 for _, rr := range f.records {
588 if strings.EqualFold(aws.ToString(rr.Name), "app.example.com.") {
589 t.Errorf("unsteered host's record survived: %s", rrKey(rr))
590 }
591 }
592 if len(f.deletedChecks) != 0 {
593 t.Fatalf("checks still in use were GC'd: %v", f.deletedChecks)
594 }
595 if len(f.checks) != checksBefore {
596 t.Fatalf("check count changed %d -> %d", checksBefore, len(f.checks))
597 }
598}
599
600// TestConvergeStaticRecordsUntouched is the deletion-safety test. The zone is
601// seeded with everything an operator's zone really holds; converge must
602// submit no change that touches any of it.
603func TestConvergeStaticRecordsUntouched(t *testing.T) {
604 f := newFake()
605 f.records = []r53types.ResourceRecordSet{
606 staticA("example.com.", "9.9.9.9"), // apex A, no SetIdentifier
607 staticA("www.example.com.", "9.9.9.9"), // plain A
608 staticA("app.example.com.", "9.9.9.9"), // plain A sharing a STEERED name
609 {Name: aws.String("example.com."), Type: r53types.RRTypeNs, TTL: aws.Int64(172800)},
610 {Name: aws.String("example.com."), Type: r53types.RRTypeSoa, TTL: aws.Int64(900)},
611 {Name: aws.String("mail.example.com."), Type: r53types.RRTypeCname, TTL: aws.Int64(300)},
612 { // a WEIGHTED A with a SetIdentifier — has an identifier but is not latency
613 Name: aws.String("weighted.example.com."), Type: r53types.RRTypeA,
614 SetIdentifier: aws.String("dublin"), Weight: aws.Int64(10), TTL: aws.Int64(60),
615 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
616 },
617 { // a LATENCY A bound to a health check that is not ours
618 Name: aws.String("legacy.example.com."), Type: r53types.RRTypeA,
619 SetIdentifier: aws.String("dublin"), Region: "eu-west-1", TTL: aws.Int64(60),
620 HealthCheckId: aws.String("hc-someone-else"),
621 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("7.7.7.7")}},
622 },
623 { // a LATENCY ALIAS record — never a shape this converger writes
624 Name: aws.String("alias.example.com."), Type: r53types.RRTypeA,
625 SetIdentifier: aws.String("dublin"), Region: "eu-west-1",
626 AliasTarget: &r53types.AliasTarget{DNSName: aws.String("elb.example.com.")},
627 },
628 }
629 before := recordNames(f.records)
630
631 buf := captureLog(t)
632 want := append([]desiredRecord{dublinRec}, hostRecs("app.example.com.", dublinRec)...)
633 mustConverge(t, f, want)
634
635 for _, c := range f.submitted {
636 if c.Action == r53types.ChangeActionDelete {
637 t.Errorf("submitted a DELETE for %s — static/foreign records are out of bounds", rrKey(*c.ResourceRecordSet))
638 }
639 if aws.ToString(c.ResourceRecordSet.SetIdentifier) == "" {
640 t.Errorf("submitted a change for a record with no SetIdentifier: %s", rrKey(*c.ResourceRecordSet))
641 }
642 }
643 for _, name := range before {
644 var still bool
645 for _, have := range recordNames(f.records) {
646 if have == name {
647 still = true
648 }
649 }
650 if !still {
651 t.Errorf("record %s disappeared", name)
652 }
653 }
654
655 // app.example.com. already holds a PLAIN A record. Route53 forbids a
656 // name from holding both a simple record and a latency record set, and
657 // change batches are atomic — so writing a latency record there would
658 // not merely fail for that name, it would take every other steering
659 // record in the zone down with it, on every tick, forever. The name is
660 // skipped entirely: no upsert, no delete, and loudly.
661 for _, c := range f.submitted {
662 if strings.EqualFold(aws.ToString(c.ResourceRecordSet.Name), "app.example.com.") {
663 t.Errorf("submitted a change for the plain-record-conflicted name: %s %s", c.Action, rrKey(*c.ResourceRecordSet))
664 }
665 }
666 if !strings.Contains(buf.String(), "SKIPPING app.example.com.") {
667 t.Errorf("the conflict was not named in the log; log = %q", buf.String())
668 }
669 if !strings.Contains(buf.String(), "9.9.9.9") {
670 t.Errorf("the log did not name the conflicting record's value; log = %q", buf.String())
671 }
672 plain := find(t, f, "app.example.com.", "")
673 if aws.ToInt64(plain.TTL) != 300 || aws.ToString(plain.ResourceRecords[0].Value) != "9.9.9.9" {
674 t.Errorf("the operator's app.example.com. A record was changed: TTL %d value %v", aws.ToInt64(plain.TTL), plain.ResourceRecords)
675 }
676
677 // ...and the OTHER name's record still lands. One conflicting name must
678 // not wedge the whole zone.
679 edgeRR := find(t, f, "edge.example.com.", "dublin")
680 if aws.ToString(edgeRR.ResourceRecords[0].Value) != "1.2.3.4" {
681 t.Errorf("the unconflicted name did not converge: %v", edgeRR.ResourceRecords)
682 }
683 if f.nChangeRecords != 1 {
684 t.Errorf("want 1 change batch (the unconflicted name), got %d", f.nChangeRecords)
685 }
686}
687
688// TestFakeRejectsConflictingBatches proves the fake's refusals are real, so
689// the skips are fixes and not tautologies: submitting the batch the converger
690// used to submit does fail, atomically, taking an unrelated and perfectly
691// legal change in the same batch down with it. One row per conflict shape.
692func TestFakeRejectsConflictingBatches(t *testing.T) {
693 tests := []struct {
694 name string
695 seeded r53types.ResourceRecordSet
696 wantErr string
697 }{
698 {
699 name: "plain A",
700 seeded: staticA("app.example.com.", "9.9.9.9"),
701 wantErr: "cannot be both a simple record and a routing-policy record set",
702 },
703 {
704 name: "CNAME",
705 seeded: r53types.ResourceRecordSet{
706 Name: aws.String("app.example.com."), Type: r53types.RRTypeCname, TTL: aws.Int64(300),
707 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("elsewhere.example.net.")}},
708 },
709 wantErr: "cannot coexist with any other record type",
710 },
711 {
712 name: "weighted A set",
713 seeded: r53types.ResourceRecordSet{
714 Name: aws.String("app.example.com."), Type: r53types.RRTypeA,
715 SetIdentifier: aws.String("legacy"), Weight: aws.Int64(10), TTL: aws.Int64(60),
716 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
717 },
718 wantErr: "mixes routing policies",
719 },
720 }
721 for _, tt := range tests {
722 t.Run(tt.name, func(t *testing.T) {
723 f := newFake()
724 f.records = []r53types.ResourceRecordSet{tt.seeded}
725
726 latency := r53types.ResourceRecordSet{
727 Name: aws.String("app.example.com."), Type: r53types.RRTypeA,
728 SetIdentifier: aws.String("dublin"), Region: "eu-west-1", TTL: aws.Int64(60),
729 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
730 }
731 innocent := r53types.ResourceRecordSet{
732 Name: aws.String("edge.example.com."), Type: r53types.RRTypeA,
733 SetIdentifier: aws.String("dublin"), Region: "eu-west-1", TTL: aws.Int64(60),
734 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
735 }
736 _, err := f.ChangeResourceRecordSets(context.Background(), &route53.ChangeResourceRecordSetsInput{
737 HostedZoneId: aws.String(testZone),
738 ChangeBatch: &r53types.ChangeBatch{Changes: []r53types.Change{
739 {Action: r53types.ChangeActionUpsert, ResourceRecordSet: &innocent},
740 {Action: r53types.ChangeActionUpsert, ResourceRecordSet: &latency},
741 }},
742 })
743 if err == nil {
744 t.Fatalf("the fake accepted the batch — it is more permissive than Route53")
745 }
746 if !strings.Contains(err.Error(), "InvalidChangeBatch") || !strings.Contains(err.Error(), tt.wantErr) {
747 t.Errorf("wrong error: %v", err)
748 }
749 // atomic: the innocent change did NOT land either
750 if len(f.records) != 1 {
751 t.Fatalf("the batch was not atomic: %v", recordNames(f.records))
752 }
753 })
754 }
755}
756
757// TestConvergeSkipsConflictingNames is FINDING A: the plain-A wedge has two
758// siblings with the identical failure mode. Each must be skipped by name, and
759// each must leave the OTHER steered names converging normally.
760func TestConvergeSkipsConflictingNames(t *testing.T) {
761 tests := []struct {
762 name string
763 seeded r53types.ResourceRecordSet
764 wantLogged string
765 }{
766 {
767 name: "a CNAME at a steered name",
768 seeded: r53types.ResourceRecordSet{
769 Name: aws.String("app.example.com."), Type: r53types.RRTypeCname, TTL: aws.Int64(300),
770 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("elsewhere.example.net.")}},
771 },
772 wantLogged: "a name holding a CNAME may hold no other record type",
773 },
774 {
775 name: "a weighted A set at a steered name",
776 seeded: r53types.ResourceRecordSet{
777 Name: aws.String("app.example.com."), Type: r53types.RRTypeA,
778 SetIdentifier: aws.String("legacy"), Weight: aws.Int64(10), TTL: aws.Int64(60),
779 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
780 },
781 wantLogged: "only one routing policy per name and type",
782 },
783 {
784 name: "a failover A set at a steered name",
785 seeded: r53types.ResourceRecordSet{
786 Name: aws.String("app.example.com."), Type: r53types.RRTypeA,
787 SetIdentifier: aws.String("primary"), Failover: r53types.ResourceRecordSetFailoverPrimary,
788 TTL: aws.Int64(60),
789 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
790 },
791 wantLogged: "only one routing policy per name and type",
792 },
793 }
794 for _, tt := range tests {
795 t.Run(tt.name, func(t *testing.T) {
796 f := newFake()
797 f.records = []r53types.ResourceRecordSet{tt.seeded}
798 seededKey := rrKey(tt.seeded)
799
800 buf := captureLog(t)
801 want := append([]desiredRecord{dublinRec}, hostRecs("app.example.com.", dublinRec)...)
802 mustConverge(t, f, want)
803
804 // the conflicted name is skipped ENTIRELY — no upsert, no delete
805 for _, c := range f.submitted {
806 if strings.EqualFold(aws.ToString(c.ResourceRecordSet.Name), "app.example.com.") {
807 t.Errorf("submitted a change for the conflicted name: %s %s", c.Action, rrKey(*c.ResourceRecordSet))
808 }
809 }
810 // the operator's record is untouched
811 var found bool
812 for _, rr := range f.records {
813 if rrKey(rr) == seededKey {
814 found = true
815 }
816 }
817 if !found {
818 t.Errorf("the operator's record disappeared; zone = %v", recordNames(f.records))
819 }
820 // the log names the conflict and why
821 logged := buf.String()
822 if !strings.Contains(logged, "SKIPPING app.example.com.") {
823 t.Errorf("the conflict was not named; log = %q", logged)
824 }
825 if !strings.Contains(logged, tt.wantLogged) {
826 t.Errorf("the log did not explain the conflict (%q); log = %q", tt.wantLogged, logged)
827 }
828 // ...and the OTHER name still converges in the same pass
829 edgeRR := find(t, f, "edge.example.com.", "dublin")
830 if aws.ToString(edgeRR.ResourceRecords[0].Value) != "1.2.3.4" {
831 t.Errorf("the unconflicted name did not converge: %v", edgeRR.ResourceRecords)
832 }
833 })
834 }
835}
836
837// TestConvergeSNIDowngradeIsLogged: when the synthetic edge.<apps-domain>
838// name is itself dropped for a conflict, every health check created
839// afterwards silently falls back to a member hostname for SNI. That
840// downgrade must be visible.
841func TestConvergeSNIDowngradeIsLogged(t *testing.T) {
842 f := newFake()
843 f.records = []r53types.ResourceRecordSet{staticA("edge.example.com.", "9.9.9.9")}
844
845 buf := captureLog(t)
846 want := append([]desiredRecord{dublinRec}, hostRecs("app.example.com.", dublinRec)...)
847 mustConverge(t, f, want)
848
849 if len(f.createdChecks) != 1 {
850 t.Fatalf("want 1 check, got %d", len(f.createdChecks))
851 }
852 if got := aws.ToString(f.createdChecks[0].HealthCheckConfig.FullyQualifiedDomainName); got != "app.example.com" {
853 t.Fatalf("SNI = %q, want the member hostname fallback", got)
854 }
855 logged := buf.String()
856 if !strings.Contains(logged, "fall back to a member hostname") {
857 t.Errorf("the SNI downgrade was silent; log = %q", logged)
858 }
859 if !strings.Contains(logged, "SKIPPING edge.example.com.") {
860 t.Errorf("the cause was not logged alongside it; log = %q", logged)
861 }
862}
863
864// TestConvergeShrinkGuard is FINDING 1: the empty-desired-set refusal only
865// catches a read that returned NOTHING. A SHORT read has a non-empty want and
866// would delete every record for the members that went missing — NXDOMAIN for
867// a live steered host. A pass that would remove most of what it manages must
868// refuse and describe itself instead.
869func TestConvergeShrinkGuard(t *testing.T) {
870 // 2 names x 2 edges = 4 managed records.
871 full := append([]desiredRecord{dublinRec, oregonRec}, hostRecs("app.example.com.", dublinRec, oregonRec)...)
872
873 t.Run("deleting 3 of 4 refuses with zero writes and names them", func(t *testing.T) {
874 f := newFake()
875 mustConverge(t, f, full)
876 before := f.writes()
877 state := recordNames(f.records)
878
879 buf := captureLog(t)
880 // A short read that saw only one edge and only the synthetic name.
881 mustConverge(t, f, []desiredRecord{dublinRec})
882
883 if got := f.writes() - before; got != 0 {
884 t.Fatalf("the refused pass made %d write calls, want 0", got)
885 }
886 if len(f.deletedChecks) != 0 {
887 t.Errorf("the refused pass GC'd checks: %v", f.deletedChecks)
888 }
889 if got := recordNames(f.records); !reflect.DeepEqual(got, state) {
890 t.Fatalf("the zone changed: %v -> %v", state, got)
891 }
892 logged := buf.String()
893 if !strings.Contains(logged, "refusing to converge") {
894 t.Errorf("the refusal was not logged; log = %q", logged)
895 }
896 // all three intended deletions named by (name, setid)
897 for _, want := range []string{
898 "would delete edge.example.com. (set oregon",
899 "would delete app.example.com. (set dublin",
900 "would delete app.example.com. (set oregon",
901 } {
902 if !strings.Contains(logged, want) {
903 t.Errorf("log does not name %q; log = %q", want, logged)
904 }
905 }
906 })
907
908 t.Run("deleting 1 of 4 proceeds", func(t *testing.T) {
909 f := newFake()
910 mustConverge(t, f, full)
911
912 // drop exactly one record: app.example.com. on oregon
913 var want []desiredRecord
914 for _, rec := range full {
915 if rec.Name == "app.example.com." && rec.Box == "oregon" {
916 continue
917 }
918 want = append(want, rec)
919 }
920 mustConverge(t, f, want)
921
922 if len(f.records) != 3 {
923 t.Fatalf("want 3 records left, got %v", recordNames(f.records))
924 }
925 for _, rr := range f.records {
926 if strings.EqualFold(aws.ToString(rr.Name), "app.example.com.") && aws.ToString(rr.SetIdentifier) == "oregon" {
927 t.Errorf("the ordinary deletion did not happen")
928 }
929 }
930 })
931
932 t.Run("the floor lets a small zone shrink", func(t *testing.T) {
933 // 2 managed records; half of 2 is 1, but the floor allows 2.
934 f := newFake()
935 two := []desiredRecord{dublinRec, oregonRec}
936 mustConverge(t, f, two)
937 mustConverge(t, f, append(hostRecs("app.example.com.", dublinRec), dublinRec))
938 if len(f.records) != 2 {
939 t.Fatalf("want 2 records, got %v", recordNames(f.records))
940 }
941 })
942}
943
944// TestShrinkLimit pins the threshold arithmetic on its own — the zone-level
945// test above can only exercise the sizes it happens to build.
946func TestShrinkLimit(t *testing.T) {
947 tests := []struct{ managed, want int }{
948 {0, shrinkFloor}, {1, shrinkFloor}, {2, shrinkFloor}, {4, shrinkFloor},
949 {6, 3}, {10, 5}, {11, 5}, {100, 50},
950 }
951 for _, tt := range tests {
952 if got := shrinkLimit(tt.managed); got != tt.want {
953 t.Errorf("shrinkLimit(%d) = %d, want %d", tt.managed, got, tt.want)
954 }
955 }
956}
957
958// TestConvergeHealthCheckGCIsTagScoped: a check must carry OUR tag, with OUR
959// zone id, before it can ever be deleted. Everything else in the account is
960// invisible to the GC.
961func TestConvergeHealthCheckGCIsTagScoped(t *testing.T) {
962 f := newFake()
963 // three checks on an EIP nobody wants any more
964 f.checks = []r53types.HealthCheck{
965 {Id: aws.String("hc-untagged"), HealthCheckConfig: &r53types.HealthCheckConfig{
966 Type: r53types.HealthCheckTypeHttps, Port: aws.Int32(443), IPAddress: aws.String("10.0.0.1")}},
967 {Id: aws.String("hc-other-zone"), HealthCheckConfig: &r53types.HealthCheckConfig{
968 Type: r53types.HealthCheckTypeHttps, Port: aws.Int32(443), IPAddress: aws.String("10.0.0.2")}},
969 {Id: aws.String("hc-ours"), HealthCheckConfig: &r53types.HealthCheckConfig{
970 Type: r53types.HealthCheckTypeHttps, Port: aws.Int32(443), IPAddress: aws.String("10.0.0.3")}},
971 {Id: aws.String("hc-other-service"), HealthCheckConfig: &r53types.HealthCheckConfig{
972 Type: r53types.HealthCheckTypeHttp, Port: aws.Int32(80), IPAddress: aws.String("10.0.0.4")}},
973 }
974 f.tags["hc-other-zone"] = []r53types.Tag{{Key: aws.String(steeringTagKey), Value: aws.String("ZSOMEOTHERZONE")}}
975 f.tags["hc-ours"] = []r53types.Tag{{Key: aws.String(steeringTagKey), Value: aws.String(testZone)}}
976 f.tags["hc-other-service"] = []r53types.Tag{{Key: aws.String("Name"), Value: aws.String("someone else's check")}}
977
978 mustConverge(t, f, []desiredRecord{dublinRec})
979
980 if len(f.deletedChecks) != 1 || f.deletedChecks[0] != "hc-ours" {
981 t.Fatalf("GC deleted %v; only the tagged-for-THIS-zone check may ever go", f.deletedChecks)
982 }
983}
984
985// TestConvergeEmptyWantRefusesTeardown: an empty desired set is a degraded
986// read (zero armed edges), not an instruction to delete production DNS.
987func TestConvergeEmptyWantRefusesTeardown(t *testing.T) {
988 f := newFake()
989 want := append([]desiredRecord{dublinRec}, hostRecs("app.example.com.", dublinRec)...)
990 mustConverge(t, f, want)
991 before := f.writes()
992 recs := len(f.records)
993
994 buf := captureLog(t)
995 if err := converge(context.Background(), f, testZone, nil); err != nil {
996 t.Fatalf("empty want should not error: %v", err)
997 }
998 if f.writes() != before {
999 t.Fatalf("empty want made %d write calls", f.writes()-before)
1000 }
1001 if len(f.records) != recs {
1002 t.Fatalf("empty want removed records: %v", recordNames(f.records))
1003 }
1004 if len(f.deletedChecks) != 0 {
1005 t.Fatalf("empty want GC'd checks: %v", f.deletedChecks)
1006 }
1007 if !strings.Contains(buf.String(), "refusing") {
1008 t.Errorf("the refusal was silent; log = %q", buf.String())
1009 }
1010}
1011
1012// TestConvergeRejectsBadZoneID: a malformed or empty zone id never reaches
1013// the API — a typo must not become a call against some other zone.
1014func TestConvergeRejectsBadZoneID(t *testing.T) {
1015 for _, zone := range []string{"", " ", "z0abc", "/hostedzone/Z0ABC", "Z0abc", "NOTAZONE"} {
1016 f := newFake()
1017 err := converge(context.Background(), f, zone, []desiredRecord{dublinRec})
1018 if err == nil {
1019 t.Errorf("zone %q accepted", zone)
1020 }
1021 if f.nListRecords+f.nListChecks+f.writes() != 0 {
1022 t.Errorf("zone %q reached the API", zone)
1023 }
1024 }
1025}
1026
1027// TestConvergeHealthCheckFQDNFallback: with no edge.<apps-domain> record in
1028// want, the check's SNI name falls back to a record's own name.
1029func TestConvergeHealthCheckFQDNFallback(t *testing.T) {
1030 f := newFake()
1031 mustConverge(t, f, hostRecs("app.example.com.", dublinRec))
1032
1033 if len(f.createdChecks) != 1 {
1034 t.Fatalf("want 1 check, got %d", len(f.createdChecks))
1035 }
1036 if got := aws.ToString(f.createdChecks[0].HealthCheckConfig.FullyQualifiedDomainName); got != "app.example.com" {
1037 t.Fatalf("FQDN = %q, want the record's own name app.example.com", got)
1038 }
1039}
1040
1041// TestConvergePagination: both listings paginate, and a converger that
1042// stopped at page one would see an empty zone and rewrite everything.
1043func TestConvergePagination(t *testing.T) {
1044 f := newFake()
1045 want := append([]desiredRecord{dublinRec, oregonRec}, hostRecs("app.example.com.", dublinRec, oregonRec)...)
1046 want = append(want, hostRecs("b.example.com.", dublinRec, oregonRec)...)
1047 want = append(want, hostRecs("c.example.com.", dublinRec, oregonRec)...)
1048 mustConverge(t, f, want)
1049
1050 f.pageSize = 1 // force many pages on the second pass
1051 before := f.writes()
1052 mustConverge(t, f, want)
1053 if got := f.writes() - before; got != 0 {
1054 t.Fatalf("paginated pass made %d writes, want 0 — a page was missed", got)
1055 }
1056 if f.nListRecords < 3 || f.nListChecks < 2 {
1057 t.Fatalf("pagination not exercised: %d record pages, %d check pages", f.nListRecords, f.nListChecks)
1058 }
1059}
1060
1061// TestConvergeTagFailureRemovesTheOrphan: a check created but not tagged can
1062// never be reused (untagged = not ours) and never GC'd — an unbounded leak,
1063// one per pass. It is deleted again before the pass fails.
1064func TestConvergeTagFailureRemovesTheOrphan(t *testing.T) {
1065 f := newFake()
1066 f.failTags = errors.New("boom")
1067 err := converge(context.Background(), f, testZone, []desiredRecord{dublinRec})
1068 if err == nil {
1069 t.Fatalf("tagging failure did not fail the pass")
1070 }
1071 if len(f.deletedChecks) != 1 {
1072 t.Fatalf("the untaggable check was leaked: created %d, deleted %v", f.nCreateCheck, f.deletedChecks)
1073 }
1074 if f.nChangeRecords != 0 {
1075 t.Errorf("records were written despite the failed pass")
1076 }
1077}
1078
1079// TestConvergeChangeFailureIsLoud: a failed batch fails the pass. The
1080// housekeeper retries next tick; it must not be swallowed.
1081func TestConvergeChangeFailureIsLoud(t *testing.T) {
1082 f := newFake()
1083 f.failChange = errors.New("InvalidChangeBatch")
1084 err := converge(context.Background(), f, testZone, []desiredRecord{dublinRec})
1085 if err == nil {
1086 t.Fatalf("a failed change batch did not fail the pass")
1087 }
1088 if !strings.Contains(err.Error(), "InvalidChangeBatch") {
1089 t.Errorf("the underlying error was not wrapped: %v", err)
1090 }
1091 // nothing may be GC'd on a pass whose record changes did not land
1092 if len(f.deletedChecks) != 0 {
1093 t.Errorf("checks GC'd after a failed record batch: %v", f.deletedChecks)
1094 }
1095}
1096
1097// TestConvergeBatchesChanges: a large desired set is chunked, and every
1098// chunk is within the batch limit.
1099func TestConvergeBatchesChanges(t *testing.T) {
1100 f := newFake()
1101 var want []desiredRecord
1102 for i := 0; i < changeBatchLimit+5; i++ {
1103 want = append(want, desiredRecord{
1104 Name: fmt.Sprintf("h%03d.example.com.", i), EIP: "1.2.3.4", Box: "dublin", Region: "eu-west-1",
1105 })
1106 }
1107 mustConverge(t, f, want)
1108 if f.nChangeRecords != 2 {
1109 t.Fatalf("want 2 batches for %d changes, got %d", len(want), f.nChangeRecords)
1110 }
1111 if len(f.records) != len(want) {
1112 t.Fatalf("want %d records, got %d", len(want), len(f.records))
1113 }
1114}
1115
1116// TestConvergeAdoptsForeignLatencyRecordUnderOurKey: a latency record already
1117// sitting at a (name, SetIdentifier) we want is taken over by UPSERT — it is
1118// exactly the record we would otherwise create, and CREATE would collide.
1119func TestConvergeAdoptsForeignLatencyRecordUnderOurKey(t *testing.T) {
1120 f := newFake()
1121 f.records = []r53types.ResourceRecordSet{{
1122 Name: aws.String("edge.example.com."), Type: r53types.RRTypeA,
1123 SetIdentifier: aws.String("dublin"), Region: "eu-west-1", TTL: aws.Int64(300),
1124 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("99.99.99.99")}},
1125 }}
1126 buf := captureLog(t)
1127 mustConverge(t, f, []desiredRecord{dublinRec})
1128
1129 rr := find(t, f, "edge.example.com.", "dublin")
1130 if aws.ToString(rr.ResourceRecords[0].Value) != "1.2.3.4" || aws.ToInt64(rr.TTL) != 60 {
1131 t.Fatalf("record not converged: %v TTL=%d", rr.ResourceRecords, aws.ToInt64(rr.TTL))
1132 }
1133 if !strings.Contains(buf.String(), "adopting") {
1134 t.Errorf("the takeover was silent; log = %q", buf.String())
1135 }
1136 // ...and it must NOT also claim to be leaving that same record alone.
1137 // The two lines describe opposite fates for one record; only one of them
1138 // is true. (Regression: the "leaving alone" log lost its wanted-key
1139 // guard when the loop was split out of recordChanges in review round 1.)
1140 if strings.Contains(buf.String(), "leaving latency record") {
1141 t.Errorf("logged BOTH adoption and 'leaving alone' for the same record; log = %q", buf.String())
1142 }
1143}
1144
1145// TestConvergeLeavingAloneLogNamesOnlyUnwantedKeys: the "leaving alone" line
1146// is still owed for a foreign latency record this pass genuinely does not
1147// touch — the FINDING B fix must not silence it everywhere.
1148func TestConvergeLeavingAloneLogNamesOnlyUnwantedKeys(t *testing.T) {
1149 f := newFake()
1150 f.records = []r53types.ResourceRecordSet{
1151 { // a foreign latency record at a name/identifier we do NOT want
1152 Name: aws.String("legacy.example.com."), Type: r53types.RRTypeA,
1153 SetIdentifier: aws.String("dublin"), Region: "eu-west-1", TTL: aws.Int64(60),
1154 HealthCheckId: aws.String("hc-someone-else"),
1155 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("7.7.7.7")}},
1156 },
1157 }
1158 buf := captureLog(t)
1159 mustConverge(t, f, []desiredRecord{dublinRec})
1160
1161 if !strings.Contains(buf.String(), "leaving latency record legacy.example.com.") {
1162 t.Errorf("the untouched foreign record was not reported; log = %q", buf.String())
1163 }
1164 if len(f.records) != 2 {
1165 t.Errorf("the foreign record was touched; zone = %v", recordNames(f.records))
1166 }
1167}
1168
1169// TestIsManagedGates pins the deletion boundary GATE BY GATE.
1170//
1171// The zone-level test above (TestConvergeStaticRecordsUntouched) proves the
1172// boundary holds for the records a real zone contains, but its fixtures fail
1173// several gates at once — so it cannot tell which gate is doing the work, and
1174// stays green if one of them is deleted. This table starts from a record that
1175// passes EVERY gate and breaks exactly one at a time. Each row must flip the
1176// verdict to unmanaged on its own.
1177func TestIsManagedGates(t *testing.T) {
1178 owned := map[string]bool{"hc-ours": true}
1179 managed := func() r53types.ResourceRecordSet {
1180 return r53types.ResourceRecordSet{
1181 Name: aws.String("app.example.com."), Type: r53types.RRTypeA,
1182 SetIdentifier: aws.String("dublin"), Region: "eu-west-1", TTL: aws.Int64(60),
1183 HealthCheckId: aws.String("hc-ours"),
1184 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
1185 }
1186 }
1187 if !isManaged(managed(), owned) {
1188 t.Fatalf("the baseline record is not managed; the table below proves nothing")
1189 }
1190
1191 tests := []struct {
1192 name string
1193 break_ func(*r53types.ResourceRecordSet)
1194 }{
1195 {"not an A record", func(rr *r53types.ResourceRecordSet) { rr.Type = r53types.RRTypeAaaa }},
1196 {"no SetIdentifier (static/operator territory)", func(rr *r53types.ResourceRecordSet) { rr.SetIdentifier = nil }},
1197 {"empty SetIdentifier", func(rr *r53types.ResourceRecordSet) { rr.SetIdentifier = aws.String("") }},
1198 {"not a latency record", func(rr *r53types.ResourceRecordSet) { rr.Region = "" }},
1199 {"an alias record", func(rr *r53types.ResourceRecordSet) {
1200 rr.AliasTarget = &r53types.AliasTarget{DNSName: aws.String("elb.example.com.")}
1201 }},
1202 {"health check we do not own", func(rr *r53types.ResourceRecordSet) { rr.HealthCheckId = aws.String("hc-theirs") }},
1203 {"no health check at all", func(rr *r53types.ResourceRecordSet) { rr.HealthCheckId = nil }},
1204 }
1205 for _, tt := range tests {
1206 t.Run(tt.name, func(t *testing.T) {
1207 rr := managed()
1208 tt.break_(&rr)
1209 if isManaged(rr, owned) {
1210 t.Fatalf("record is still managed — this gate does not stand on its own")
1211 }
1212 })
1213 }
1214
1215 // and the empty-ownership case: if the tag listing found nothing ours,
1216 // NOTHING in the zone is deletable.
1217 if isManaged(managed(), map[string]bool{}) {
1218 t.Errorf("a record is managed with no owned health checks at all")
1219 }
1220}
1221
1222// TestSteeringZoneIDFromEnv pins the dormancy contract Task 6 consumes.
1223func TestSteeringZoneIDFromEnv(t *testing.T) {
1224 tests := []struct {
1225 set string
1226 want string
1227 }{
1228 {"", ""},
1229 {" ", ""},
1230 {"Z0STEERINGTEST", "Z0STEERINGTEST"},
1231 {" Z0STEERINGTEST ", "Z0STEERINGTEST"},
1232 {"/hostedzone/Z0STEERINGTEST", ""},
1233 {"z0steeringtest", ""},
1234 }
1235 for _, tt := range tests {
1236 t.Setenv(steeringZoneEnv, tt.set)
1237 if got := SteeringZoneID(); got != tt.want {
1238 t.Errorf("SteeringZoneID() with %q = %q, want %q", tt.set, got, tt.want)
1239 }
1240 }
1241}
1242