forked from kubernetes-sigs/external-dns
-
Notifications
You must be signed in to change notification settings - Fork 0
/
aws.go
1017 lines (894 loc) · 37.6 KB
/
aws.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package aws
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/service/route53"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/plan"
"sigs.k8s.io/external-dns/provider"
)
const (
recordTTL = 300
// From the experiments, it seems that the default MaxItems applied is 100,
// and that, on the server side, there is a hard limit of 300 elements per page.
// After a discussion with AWS representants, clients should accept
// when less items are returned, and still paginate accordingly.
// As we are using the standard AWS client, this should already be compliant.
// Hence, ifever AWS decides to raise this limit, we will automatically reduce the pressure on rate limits
route53PageSize = "300"
// providerSpecificAlias specifies whether a CNAME endpoint maps to an AWS ALIAS record.
providerSpecificAlias = "alias"
providerSpecificTargetHostedZone = "aws/target-hosted-zone"
// providerSpecificEvaluateTargetHealth specifies whether an AWS ALIAS record
// has the EvaluateTargetHealth field set to true. Present iff the endpoint
// has a `providerSpecificAlias` value of `true`.
providerSpecificEvaluateTargetHealth = "aws/evaluate-target-health"
providerSpecificWeight = "aws/weight"
providerSpecificRegion = "aws/region"
providerSpecificFailover = "aws/failover"
providerSpecificGeolocationContinentCode = "aws/geolocation-continent-code"
providerSpecificGeolocationCountryCode = "aws/geolocation-country-code"
providerSpecificGeolocationSubdivisionCode = "aws/geolocation-subdivision-code"
providerSpecificMultiValueAnswer = "aws/multi-value-answer"
providerSpecificHealthCheckID = "aws/health-check-id"
sameZoneAlias = "same-zone"
)
// see: https://docs.aws.amazon.com/general/latest/gr/elb.html
var canonicalHostedZones = map[string]string{
// Application Load Balancers and Classic Load Balancers
"us-east-2.elb.amazonaws.com": "Z3AADJGX6KTTL2",
"us-east-1.elb.amazonaws.com": "Z35SXDOTRQ7X7K",
"us-west-1.elb.amazonaws.com": "Z368ELLRRE2KJ0",
"us-west-2.elb.amazonaws.com": "Z1H1FL5HABSF5",
"ca-central-1.elb.amazonaws.com": "ZQSVJUPU6J1EY",
"ap-east-1.elb.amazonaws.com": "Z3DQVH9N71FHZ0",
"ap-south-1.elb.amazonaws.com": "ZP97RAFLXTNZK",
"ap-northeast-2.elb.amazonaws.com": "ZWKZPGTI48KDX",
"ap-northeast-3.elb.amazonaws.com": "Z5LXEXXYW11ES",
"ap-southeast-1.elb.amazonaws.com": "Z1LMS91P8CMLE5",
"ap-southeast-2.elb.amazonaws.com": "Z1GM3OXH4ZPM65",
"ap-southeast-3.elb.amazonaws.com": "Z08888821HLRG5A9ZRTER",
"ap-northeast-1.elb.amazonaws.com": "Z14GRHDCWA56QT",
"eu-central-1.elb.amazonaws.com": "Z215JYRZR1TBD5",
"eu-central-2.elb.amazonaws.com": "Z06391101F2ZOEP8P5EB3",
"eu-west-1.elb.amazonaws.com": "Z32O12XQLNTSW2",
"eu-west-2.elb.amazonaws.com": "ZHURV8PSTC4K8",
"eu-west-3.elb.amazonaws.com": "Z3Q77PNBQS71R4",
"eu-north-1.elb.amazonaws.com": "Z23TAZ6LKFMNIO",
"eu-south-1.elb.amazonaws.com": "Z3ULH7SSC9OV64",
"eu-south-2.elb.amazonaws.com": "Z0956581394HF5D5LXGAP",
"sa-east-1.elb.amazonaws.com": "Z2P70J7HTTTPLU",
"cn-north-1.elb.amazonaws.com.cn": "Z1GDH35T77C1KE",
"cn-northwest-1.elb.amazonaws.com.cn": "ZM7IZAIOVVDZF",
"us-gov-west-1.elb.amazonaws.com": "Z33AYJ8TM3BH4J",
"us-gov-east-1.elb.amazonaws.com": "Z166TLBEWOO7G0",
"me-central-1.elb.amazonaws.com": "Z08230872XQRWHG2XF6I",
"me-south-1.elb.amazonaws.com": "ZS929ML54UICD",
"af-south-1.elb.amazonaws.com": "Z268VQBMOI5EKX",
// Network Load Balancers
"elb.us-east-2.amazonaws.com": "ZLMOA37VPKANP",
"elb.us-east-1.amazonaws.com": "Z26RNL4JYFTOTI",
"elb.us-west-1.amazonaws.com": "Z24FKFUX50B4VW",
"elb.us-west-2.amazonaws.com": "Z18D5FSROUN65G",
"elb.ca-central-1.amazonaws.com": "Z2EPGBW3API2WT",
"elb.ap-east-1.amazonaws.com": "Z12Y7K3UBGUAD1",
"elb.ap-south-1.amazonaws.com": "ZVDDRBQ08TROA",
"elb.ap-northeast-3.amazonaws.com": "Z1GWIQ4HH19I5X",
"elb.ap-northeast-2.amazonaws.com": "ZIBE1TIR4HY56",
"elb.ap-southeast-1.amazonaws.com": "ZKVM4W9LS7TM",
"elb.ap-southeast-2.amazonaws.com": "ZCT6FZBF4DROD",
"elb.ap-southeast-3.amazonaws.com": "Z01971771FYVNCOVWJU1G",
"elb.ap-northeast-1.amazonaws.com": "Z31USIVHYNEOWT",
"elb.eu-central-1.amazonaws.com": "Z3F0SRJ5LGBH90",
"elb.eu-central-2.amazonaws.com": "Z02239872DOALSIDCX66S",
"elb.eu-west-1.amazonaws.com": "Z2IFOLAFXWLO4F",
"elb.eu-west-2.amazonaws.com": "ZD4D7Y8KGAS4G",
"elb.eu-west-3.amazonaws.com": "Z1CMS0P5QUZ6D5",
"elb.eu-north-1.amazonaws.com": "Z1UDT6IFJ4EJM",
"elb.eu-south-1.amazonaws.com": "Z23146JA1KNAFP",
"elb.eu-south-2.amazonaws.com": "Z1011216NVTVYADP1SSV",
"elb.sa-east-1.amazonaws.com": "ZTK26PT1VY4CU",
"elb.cn-north-1.amazonaws.com.cn": "Z3QFB96KMJ7ED6",
"elb.cn-northwest-1.amazonaws.com.cn": "ZQEIKTCZ8352D",
"elb.us-gov-west-1.amazonaws.com": "ZMG1MZ2THAWF1",
"elb.us-gov-east-1.amazonaws.com": "Z1ZSMQQ6Q24QQ8",
"elb.me-central-1.amazonaws.com": "Z00282643NTTLPANJJG2P",
"elb.me-south-1.amazonaws.com": "Z3QSRYVP46NYYV",
"elb.af-south-1.amazonaws.com": "Z203XCE67M25HM",
// Global Accelerator
"awsglobalaccelerator.com": "Z2BJ6XQ5FK7U4H",
// Cloudfront
"cloudfront.net": "Z2FDTNDATAQYW2",
// VPC Endpoint (PrivateLink)
"eu-west-2.vpce.amazonaws.com": "Z7K1066E3PUKB",
"us-east-2.vpce.amazonaws.com": "ZC8PG0KIFKBRI",
"af-south-1.vpce.amazonaws.com": "Z09302161J80N9A7UTP7U",
"ap-east-1.vpce.amazonaws.com": "Z2LIHJ7PKBEMWN",
"ap-northeast-1.vpce.amazonaws.com": "Z2E726K9Y6RL4W",
"ap-northeast-2.vpce.amazonaws.com": "Z27UANNT0PRK1T",
"ap-northeast-3.vpce.amazonaws.com": "Z376B5OMM2JZL2",
"ap-south-1.vpce.amazonaws.com": "Z2KVTB3ZLFM7JR",
"ap-south-2.vpce.amazonaws.com": "Z0952991RWSF5AHIQDIY",
"ap-southeast-1.vpce.amazonaws.com": "Z18LLCSTV4NVNL",
"ap-southeast-2.vpce.amazonaws.com": "ZDK2GCRPAFKGO",
"ap-southeast-3.vpce.amazonaws.com": "Z03881013RZ9BYYZO8N5W",
"ap-southeast-4.vpce.amazonaws.com": "Z07508191CO1RNBX3X3AU",
"ca-central-1.vpce.amazonaws.com": "ZRCXCF510Y6P9",
"eu-central-1.vpce.amazonaws.com": "Z273ZU8SZ5RJPC",
"eu-central-2.vpce.amazonaws.com": "Z045369019J4FUQ4S272E",
"eu-north-1.vpce.amazonaws.com": "Z3OWWK6JFDEDGC",
"eu-south-1.vpce.amazonaws.com": "Z2A5FDNRLY7KZG",
"eu-south-2.vpce.amazonaws.com": "Z014396544HENR57XQCJ",
"eu-west-1.vpce.amazonaws.com": "Z38GZ743OKFT7T",
"eu-west-3.vpce.amazonaws.com": "Z1DWHTMFP0WECP",
"me-central-1.vpce.amazonaws.com": "Z07122992YCEUCB9A9570",
"me-south-1.vpce.amazonaws.com": "Z3B95P3VBGEQGY",
"sa-east-1.vpce.amazonaws.com": "Z2LXUWEVLCVZIB",
"us-east-1.vpce.amazonaws.com": "Z7HUB22UULQXV",
"us-gov-east-1.vpce.amazonaws.com": "Z2MU5TEIGO9WXB",
"us-gov-west-1.vpce.amazonaws.com": "Z12529ZODG2B6H",
"us-west-1.vpce.amazonaws.com": "Z12I86A8N7VCZO",
"us-west-2.vpce.amazonaws.com": "Z1YSA3EXCYUU9Z",
}
// Route53API is the subset of the AWS Route53 API that we actually use. Add methods as required. Signatures must match exactly.
// mostly taken from: https://github.com/kubernetes/kubernetes/blob/853167624edb6bc0cfdcdfb88e746e178f5db36c/federation/pkg/dnsprovider/providers/aws/route53/stubs/route53api.go
type Route53API interface {
ListResourceRecordSetsPagesWithContext(ctx context.Context, input *route53.ListResourceRecordSetsInput, fn func(resp *route53.ListResourceRecordSetsOutput, lastPage bool) (shouldContinue bool), opts ...request.Option) error
ChangeResourceRecordSetsWithContext(ctx context.Context, input *route53.ChangeResourceRecordSetsInput, opts ...request.Option) (*route53.ChangeResourceRecordSetsOutput, error)
CreateHostedZoneWithContext(ctx context.Context, input *route53.CreateHostedZoneInput, opts ...request.Option) (*route53.CreateHostedZoneOutput, error)
ListHostedZonesPagesWithContext(ctx context.Context, input *route53.ListHostedZonesInput, fn func(resp *route53.ListHostedZonesOutput, lastPage bool) (shouldContinue bool), opts ...request.Option) error
ListTagsForResourceWithContext(ctx context.Context, input *route53.ListTagsForResourceInput, opts ...request.Option) (*route53.ListTagsForResourceOutput, error)
}
// wrapper to handle ownership relation throughout the provider implementation
type Route53Change struct {
route53.Change
OwnedRecord string
}
type Route53Changes []*Route53Change
func (cs Route53Changes) Route53Changes() []*route53.Change {
ret := []*route53.Change{}
for _, c := range cs {
ret = append(ret, &c.Change)
}
return ret
}
type zonesListCache struct {
age time.Time
duration time.Duration
zones map[string]*route53.HostedZone
}
// AWSProvider is an implementation of Provider for AWS Route53.
type AWSProvider struct {
provider.BaseProvider
client Route53API
dryRun bool
batchChangeSize int
batchChangeInterval time.Duration
evaluateTargetHealth bool
// only consider hosted zones managing domains ending in this suffix
domainFilter endpoint.DomainFilter
// filter hosted zones by id
zoneIDFilter provider.ZoneIDFilter
// filter hosted zones by type (e.g. private or public)
zoneTypeFilter provider.ZoneTypeFilter
// filter hosted zones by tags
zoneTagFilter provider.ZoneTagFilter
preferCNAME bool
zonesCache *zonesListCache
// queue for collecting changes to submit them in the next iteration, but after all other changes
failedChangesQueue map[string]Route53Changes
}
// AWSConfig contains configuration to create a new AWS provider.
type AWSConfig struct {
DomainFilter endpoint.DomainFilter
ZoneIDFilter provider.ZoneIDFilter
ZoneTypeFilter provider.ZoneTypeFilter
ZoneTagFilter provider.ZoneTagFilter
BatchChangeSize int
BatchChangeInterval time.Duration
EvaluateTargetHealth bool
PreferCNAME bool
DryRun bool
ZoneCacheDuration time.Duration
}
// NewAWSProvider initializes a new AWS Route53 based Provider.
func NewAWSProvider(awsConfig AWSConfig, client Route53API) (*AWSProvider, error) {
provider := &AWSProvider{
client: client,
domainFilter: awsConfig.DomainFilter,
zoneIDFilter: awsConfig.ZoneIDFilter,
zoneTypeFilter: awsConfig.ZoneTypeFilter,
zoneTagFilter: awsConfig.ZoneTagFilter,
batchChangeSize: awsConfig.BatchChangeSize,
batchChangeInterval: awsConfig.BatchChangeInterval,
evaluateTargetHealth: awsConfig.EvaluateTargetHealth,
preferCNAME: awsConfig.PreferCNAME,
dryRun: awsConfig.DryRun,
zonesCache: &zonesListCache{duration: awsConfig.ZoneCacheDuration},
failedChangesQueue: make(map[string]Route53Changes),
}
return provider, nil
}
// Zones returns the list of hosted zones.
func (p *AWSProvider) Zones(ctx context.Context) (map[string]*route53.HostedZone, error) {
if p.zonesCache.zones != nil && time.Since(p.zonesCache.age) < p.zonesCache.duration {
log.Debug("Using cached zones list")
return p.zonesCache.zones, nil
}
log.Debug("Refreshing zones list cache")
zones := make(map[string]*route53.HostedZone)
var tagErr error
f := func(resp *route53.ListHostedZonesOutput, lastPage bool) (shouldContinue bool) {
for _, zone := range resp.HostedZones {
if !p.zoneIDFilter.Match(aws.StringValue(zone.Id)) {
continue
}
if !p.zoneTypeFilter.Match(zone) {
continue
}
if !p.domainFilter.Match(aws.StringValue(zone.Name)) {
continue
}
// Only fetch tags if a tag filter was specified
if !p.zoneTagFilter.IsEmpty() {
tags, err := p.tagsForZone(ctx, *zone.Id)
if err != nil {
tagErr = err
return false
}
if !p.zoneTagFilter.Match(tags) {
continue
}
}
zones[aws.StringValue(zone.Id)] = zone
}
return true
}
err := p.client.ListHostedZonesPagesWithContext(ctx, &route53.ListHostedZonesInput{}, f)
if err != nil {
return nil, errors.Wrap(err, "failed to list hosted zones")
}
if tagErr != nil {
return nil, errors.Wrap(tagErr, "failed to list zones tags")
}
for _, zone := range zones {
log.Debugf("Considering zone: %s (domain: %s)", aws.StringValue(zone.Id), aws.StringValue(zone.Name))
}
if p.zonesCache.duration > time.Duration(0) {
p.zonesCache.zones = zones
p.zonesCache.age = time.Now()
}
return zones, nil
}
// wildcardUnescape converts \\052.abc back to *.abc
// Route53 stores wildcards escaped: http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html?shortFooter=true#domain-name-format-asterisk
func wildcardUnescape(s string) string {
return strings.Replace(s, "\\052", "*", 1)
}
// Records returns the list of records in a given hosted zone.
func (p *AWSProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) {
zones, err := p.Zones(ctx)
if err != nil {
return nil, errors.Wrap(err, "records retrieval failed")
}
return p.records(ctx, zones)
}
func (p *AWSProvider) records(ctx context.Context, zones map[string]*route53.HostedZone) ([]*endpoint.Endpoint, error) {
endpoints := make([]*endpoint.Endpoint, 0)
f := func(resp *route53.ListResourceRecordSetsOutput, lastPage bool) (shouldContinue bool) {
for _, r := range resp.ResourceRecordSets {
newEndpoints := make([]*endpoint.Endpoint, 0)
if !p.SupportedRecordType(aws.StringValue(r.Type)) {
continue
}
var ttl endpoint.TTL
if r.TTL != nil {
ttl = endpoint.TTL(*r.TTL)
}
if len(r.ResourceRecords) > 0 {
targets := make([]string, len(r.ResourceRecords))
for idx, rr := range r.ResourceRecords {
targets[idx] = aws.StringValue(rr.Value)
}
ep := endpoint.NewEndpointWithTTL(wildcardUnescape(aws.StringValue(r.Name)), aws.StringValue(r.Type), ttl, targets...)
if aws.StringValue(r.Type) == endpoint.RecordTypeCNAME {
ep = ep.WithProviderSpecific(providerSpecificAlias, "false")
}
newEndpoints = append(newEndpoints, ep)
}
if r.AliasTarget != nil {
// Alias records don't have TTLs so provide the default to match the TXT generation
if ttl == 0 {
ttl = recordTTL
}
ep := endpoint.
NewEndpointWithTTL(wildcardUnescape(aws.StringValue(r.Name)), endpoint.RecordTypeCNAME, ttl, aws.StringValue(r.AliasTarget.DNSName)).
WithProviderSpecific(providerSpecificEvaluateTargetHealth, fmt.Sprintf("%t", aws.BoolValue(r.AliasTarget.EvaluateTargetHealth))).
WithProviderSpecific(providerSpecificAlias, "true")
newEndpoints = append(newEndpoints, ep)
}
for _, ep := range newEndpoints {
if r.SetIdentifier != nil {
ep.SetIdentifier = aws.StringValue(r.SetIdentifier)
switch {
case r.Weight != nil:
ep.WithProviderSpecific(providerSpecificWeight, fmt.Sprintf("%d", aws.Int64Value(r.Weight)))
case r.Region != nil:
ep.WithProviderSpecific(providerSpecificRegion, aws.StringValue(r.Region))
case r.Failover != nil:
ep.WithProviderSpecific(providerSpecificFailover, aws.StringValue(r.Failover))
case r.MultiValueAnswer != nil && aws.BoolValue(r.MultiValueAnswer):
ep.WithProviderSpecific(providerSpecificMultiValueAnswer, "")
case r.GeoLocation != nil:
if r.GeoLocation.ContinentCode != nil {
ep.WithProviderSpecific(providerSpecificGeolocationContinentCode, aws.StringValue(r.GeoLocation.ContinentCode))
} else {
if r.GeoLocation.CountryCode != nil {
ep.WithProviderSpecific(providerSpecificGeolocationCountryCode, aws.StringValue(r.GeoLocation.CountryCode))
}
if r.GeoLocation.SubdivisionCode != nil {
ep.WithProviderSpecific(providerSpecificGeolocationSubdivisionCode, aws.StringValue(r.GeoLocation.SubdivisionCode))
}
}
default:
// one of the above needs to be set, otherwise SetIdentifier doesn't make sense
}
}
if r.HealthCheckId != nil {
ep.WithProviderSpecific(providerSpecificHealthCheckID, aws.StringValue(r.HealthCheckId))
}
endpoints = append(endpoints, ep)
}
}
return true
}
for _, z := range zones {
params := &route53.ListResourceRecordSetsInput{
HostedZoneId: z.Id,
MaxItems: aws.String(route53PageSize),
}
if err := p.client.ListResourceRecordSetsPagesWithContext(ctx, params, f); err != nil {
return nil, errors.Wrapf(err, "failed to list resource records sets for zone %s", *z.Id)
}
}
return endpoints, nil
}
// Identify if old and new endpoints require DELETE/CREATE instead of UPDATE.
func (p *AWSProvider) requiresDeleteCreate(old *endpoint.Endpoint, new *endpoint.Endpoint) bool {
// a change of record type
if old.RecordType != new.RecordType {
return true
}
// an ALIAS record change to/from a CNAME
if old.RecordType == endpoint.RecordTypeCNAME {
oldAlias, _ := old.GetProviderSpecificProperty(providerSpecificAlias)
newAlias, _ := new.GetProviderSpecificProperty(providerSpecificAlias)
if oldAlias != newAlias {
return true
}
}
// a set identifier change
if old.SetIdentifier != new.SetIdentifier {
return true
}
// a change of routing policy
// default to true for geolocation properties if any geolocation property exists in old/new but not the other
for _, propType := range [7]string{providerSpecificWeight, providerSpecificRegion, providerSpecificFailover,
providerSpecificFailover, providerSpecificGeolocationContinentCode, providerSpecificGeolocationCountryCode,
providerSpecificGeolocationSubdivisionCode} {
_, oldPolicy := old.GetProviderSpecificProperty(propType)
_, newPolicy := new.GetProviderSpecificProperty(propType)
if oldPolicy != newPolicy {
return true
}
}
return false
}
func (p *AWSProvider) createUpdateChanges(newEndpoints, oldEndpoints []*endpoint.Endpoint) Route53Changes {
var deletes []*endpoint.Endpoint
var creates []*endpoint.Endpoint
var updates []*endpoint.Endpoint
for i, new := range newEndpoints {
old := oldEndpoints[i]
if p.requiresDeleteCreate(old, new) {
deletes = append(deletes, old)
creates = append(creates, new)
} else {
// Safe to perform an UPSERT.
updates = append(updates, new)
}
}
combined := make(Route53Changes, 0, len(deletes)+len(creates)+len(updates))
combined = append(combined, p.newChanges(route53.ChangeActionCreate, creates)...)
combined = append(combined, p.newChanges(route53.ChangeActionUpsert, updates)...)
combined = append(combined, p.newChanges(route53.ChangeActionDelete, deletes)...)
return combined
}
// GetDomainFilter generates a filter to exclude any domain that is not controlled by the provider
func (p *AWSProvider) GetDomainFilter() endpoint.DomainFilter {
zones, err := p.Zones(context.Background())
if err != nil {
log.Errorf("failed to list zones: %v", err)
return endpoint.DomainFilter{}
}
zoneNames := []string(nil)
for _, z := range zones {
zoneNames = append(zoneNames, aws.StringValue(z.Name), "."+aws.StringValue(z.Name))
}
log.Infof("Applying provider record filter for domains: %v", zoneNames)
return endpoint.NewDomainFilter(zoneNames)
}
// ApplyChanges applies a given set of changes in a given zone.
func (p *AWSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
zones, err := p.Zones(ctx)
if err != nil {
return errors.Wrap(err, "failed to list zones, not applying changes")
}
updateChanges := p.createUpdateChanges(changes.UpdateNew, changes.UpdateOld)
combinedChanges := make(Route53Changes, 0, len(changes.Delete)+len(changes.Create)+len(updateChanges))
combinedChanges = append(combinedChanges, p.newChanges(route53.ChangeActionCreate, changes.Create)...)
combinedChanges = append(combinedChanges, p.newChanges(route53.ChangeActionDelete, changes.Delete)...)
combinedChanges = append(combinedChanges, updateChanges...)
return p.submitChanges(ctx, combinedChanges, zones)
}
// submitChanges takes a zone and a collection of Changes and sends them as a single transaction.
func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes, zones map[string]*route53.HostedZone) error {
// return early if there is nothing to change
if len(changes) == 0 {
log.Info("All records are already up to date")
return nil
}
// separate into per-zone change sets to be passed to the API.
changesByZone := changesByZone(zones, changes)
if len(changesByZone) == 0 {
log.Info("All records are already up to date, there are no changes for the matching hosted zones")
}
var failedZones []string
for z, cs := range changesByZone {
var failedUpdate bool
// group changes into new changes and into changes that failed in a previous iteration and are retried
retriedChanges, newChanges := findChangesInQueue(cs, p.failedChangesQueue[z])
p.failedChangesQueue[z] = nil
batchCs := append(batchChangeSet(newChanges, p.batchChangeSize), batchChangeSet(retriedChanges, p.batchChangeSize)...)
for i, b := range batchCs {
if len(b) == 0 {
continue
}
for _, c := range b {
log.Infof("Desired change: %s %s %s [Id: %s]", *c.Action, *c.ResourceRecordSet.Name, *c.ResourceRecordSet.Type, z)
}
if !p.dryRun {
params := &route53.ChangeResourceRecordSetsInput{
HostedZoneId: aws.String(z),
ChangeBatch: &route53.ChangeBatch{
Changes: b.Route53Changes(),
},
}
successfulChanges := 0
if _, err := p.client.ChangeResourceRecordSetsWithContext(ctx, params); err != nil {
log.Errorf("Failure in zone %s [Id: %s] when submitting change batch: %v", aws.StringValue(zones[z].Name), z, err)
changesByOwnership := groupChangesByNameAndOwnershipRelation(b)
if len(changesByOwnership) > 1 {
log.Debug("Trying to submit change sets one-by-one instead")
for _, changes := range changesByOwnership {
for _, c := range changes {
log.Debugf("Desired change: %s %s %s [Id: %s]", *c.Action, *c.ResourceRecordSet.Name, *c.ResourceRecordSet.Type, z)
}
params.ChangeBatch = &route53.ChangeBatch{
Changes: changes.Route53Changes(),
}
if _, err := p.client.ChangeResourceRecordSetsWithContext(ctx, params); err != nil {
failedUpdate = true
log.Errorf("Failed submitting change (error: %v), it will be retried in a separate change batch in the next iteration", err)
p.failedChangesQueue[z] = append(p.failedChangesQueue[z], changes...)
} else {
successfulChanges = successfulChanges + len(changes)
}
}
} else {
failedUpdate = true
}
} else {
successfulChanges = len(b)
}
if successfulChanges > 0 {
// z is the R53 Hosted Zone ID already as aws.StringValue
log.Infof("%d record(s) in zone %s [Id: %s] were successfully updated", successfulChanges, aws.StringValue(zones[z].Name), z)
}
if i != len(batchCs)-1 {
time.Sleep(p.batchChangeInterval)
}
}
}
if failedUpdate {
failedZones = append(failedZones, z)
}
}
if len(failedZones) > 0 {
return errors.Errorf("failed to submit all changes for the following zones: %v", failedZones)
}
return nil
}
// newChanges returns a collection of Changes based on the given records and action.
func (p *AWSProvider) newChanges(action string, endpoints []*endpoint.Endpoint) Route53Changes {
changes := make(Route53Changes, 0, len(endpoints))
for _, endpoint := range endpoints {
change, dualstack := p.newChange(action, endpoint)
changes = append(changes, change)
if dualstack {
// make a copy of change, modify RRS type to AAAA, then add new change
rrs := *change.ResourceRecordSet
change2 := &Route53Change{Change: route53.Change{Action: change.Action, ResourceRecordSet: &rrs}}
change2.ResourceRecordSet.Type = aws.String(route53.RRTypeAaaa)
changes = append(changes, change2)
}
}
return changes
}
// AdjustEndpoints modifies the provided endpoints (coming from various sources) to match
// the endpoints that the provider returns in `Records` so that the change plan will not have
// unneeded (potentially failing) changes.
// Example: CNAME endpoints pointing to ELBs will have a `alias` provider-specific property
// added to match the endpoints generated from existing alias records in Route53.
func (p *AWSProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint {
for _, ep := range endpoints {
alias := false
if ep.RecordType != endpoint.RecordTypeCNAME {
ep.DeleteProviderSpecificProperty(providerSpecificAlias)
} else if aliasString, ok := ep.GetProviderSpecificProperty(providerSpecificAlias); ok {
alias = aliasString == "true"
if !alias && aliasString != "false" {
ep.SetProviderSpecificProperty(providerSpecificAlias, "false")
}
} else {
alias = useAlias(ep, p.preferCNAME)
log.Debugf("Modifying endpoint: %v, setting %s=%v", ep, providerSpecificAlias, alias)
ep.SetProviderSpecificProperty(providerSpecificAlias, strconv.FormatBool(alias))
}
if alias {
if ep.RecordTTL.IsConfigured() {
log.Debugf("Modifying endpoint: %v, setting ttl=%v", ep, recordTTL)
ep.RecordTTL = recordTTL
}
if prop, ok := ep.GetProviderSpecificProperty(providerSpecificEvaluateTargetHealth); ok {
if prop != "true" && prop != "false" {
ep.SetProviderSpecificProperty(providerSpecificEvaluateTargetHealth, "false")
}
} else {
ep.SetProviderSpecificProperty(providerSpecificEvaluateTargetHealth, strconv.FormatBool(p.evaluateTargetHealth))
}
} else {
ep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth)
}
}
return endpoints
}
// newChange returns a route53 Change and a boolean indicating if there should also be a change to a AAAA record
// returned Change is based on the given record by the given action, e.g.
// action=ChangeActionCreate returns a change for creation of the record and
// action=ChangeActionDelete returns a change for deletion of the record.
func (p *AWSProvider) newChange(action string, ep *endpoint.Endpoint) (*Route53Change, bool) {
change := &Route53Change{
Change: route53.Change{
Action: aws.String(action),
ResourceRecordSet: &route53.ResourceRecordSet{
Name: aws.String(ep.DNSName),
},
},
}
dualstack := false
if targetHostedZone := isAWSAlias(ep); targetHostedZone != "" {
evalTargetHealth := p.evaluateTargetHealth
if prop, ok := ep.GetProviderSpecificProperty(providerSpecificEvaluateTargetHealth); ok {
evalTargetHealth = prop == "true"
}
// If the endpoint has a Dualstack label, append a change for AAAA record as well.
if val, ok := ep.Labels[endpoint.DualstackLabelKey]; ok {
dualstack = val == "true"
}
change.ResourceRecordSet.Type = aws.String(route53.RRTypeA)
change.ResourceRecordSet.AliasTarget = &route53.AliasTarget{
DNSName: aws.String(ep.Targets[0]),
HostedZoneId: aws.String(cleanZoneID(targetHostedZone)),
EvaluateTargetHealth: aws.Bool(evalTargetHealth),
}
} else {
change.ResourceRecordSet.Type = aws.String(ep.RecordType)
if !ep.RecordTTL.IsConfigured() {
change.ResourceRecordSet.TTL = aws.Int64(recordTTL)
} else {
change.ResourceRecordSet.TTL = aws.Int64(int64(ep.RecordTTL))
}
change.ResourceRecordSet.ResourceRecords = make([]*route53.ResourceRecord, len(ep.Targets))
for idx, val := range ep.Targets {
change.ResourceRecordSet.ResourceRecords[idx] = &route53.ResourceRecord{
Value: aws.String(val),
}
}
}
setIdentifier := ep.SetIdentifier
if setIdentifier != "" {
change.ResourceRecordSet.SetIdentifier = aws.String(setIdentifier)
if prop, ok := ep.GetProviderSpecificProperty(providerSpecificWeight); ok {
weight, err := strconv.ParseInt(prop, 10, 64)
if err != nil {
log.Errorf("Failed parsing value of %s: %s: %v; using weight of 0", providerSpecificWeight, prop, err)
weight = 0
}
change.ResourceRecordSet.Weight = aws.Int64(weight)
}
if prop, ok := ep.GetProviderSpecificProperty(providerSpecificRegion); ok {
change.ResourceRecordSet.Region = aws.String(prop)
}
if prop, ok := ep.GetProviderSpecificProperty(providerSpecificFailover); ok {
change.ResourceRecordSet.Failover = aws.String(prop)
}
if _, ok := ep.GetProviderSpecificProperty(providerSpecificMultiValueAnswer); ok {
change.ResourceRecordSet.MultiValueAnswer = aws.Bool(true)
}
geolocation := &route53.GeoLocation{}
useGeolocation := false
if prop, ok := ep.GetProviderSpecificProperty(providerSpecificGeolocationContinentCode); ok {
geolocation.ContinentCode = aws.String(prop)
useGeolocation = true
} else {
if prop, ok := ep.GetProviderSpecificProperty(providerSpecificGeolocationCountryCode); ok {
geolocation.CountryCode = aws.String(prop)
useGeolocation = true
}
if prop, ok := ep.GetProviderSpecificProperty(providerSpecificGeolocationSubdivisionCode); ok {
geolocation.SubdivisionCode = aws.String(prop)
useGeolocation = true
}
}
if useGeolocation {
change.ResourceRecordSet.GeoLocation = geolocation
}
}
if prop, ok := ep.GetProviderSpecificProperty(providerSpecificHealthCheckID); ok {
change.ResourceRecordSet.HealthCheckId = aws.String(prop)
}
if ownedRecord, ok := ep.Labels[endpoint.OwnedRecordLabelKey]; ok {
change.OwnedRecord = ownedRecord
}
return change, dualstack
}
// searches for `changes` that are contained in `queue` and returns the `changes` separated by whether they were found in the queue (`foundChanges`) or not (`notFoundChanges`)
func findChangesInQueue(changes Route53Changes, queue Route53Changes) (foundChanges, notFoundChanges Route53Changes) {
if queue == nil {
return Route53Changes{}, changes
}
for _, c := range changes {
found := false
for _, qc := range queue {
if c == qc {
foundChanges = append(foundChanges, c)
found = true
break
}
}
if !found {
notFoundChanges = append(notFoundChanges, c)
}
}
return
}
// group the given changes by name and ownership relation to ensure these are always submitted in the same transaction to Route53;
// grouping by name is done to always submit changes with the same name but different set identifier together,
// grouping by ownership relation is done to always submit changes of records and e.g. their corresponding TXT registry records together
func groupChangesByNameAndOwnershipRelation(cs Route53Changes) map[string]Route53Changes {
changesByOwnership := make(map[string]Route53Changes)
for _, v := range cs {
key := v.OwnedRecord
if key == "" {
key = aws.StringValue(v.ResourceRecordSet.Name)
}
changesByOwnership[key] = append(changesByOwnership[key], v)
}
return changesByOwnership
}
func (p *AWSProvider) tagsForZone(ctx context.Context, zoneID string) (map[string]string, error) {
response, err := p.client.ListTagsForResourceWithContext(ctx, &route53.ListTagsForResourceInput{
ResourceType: aws.String("hostedzone"),
ResourceId: aws.String(zoneID),
})
if err != nil {
return nil, errors.Wrapf(err, "failed to list tags for zone %s", zoneID)
}
tagMap := map[string]string{}
for _, tag := range response.ResourceTagSet.Tags {
tagMap[*tag.Key] = *tag.Value
}
return tagMap, nil
}
func batchChangeSet(cs Route53Changes, batchSize int) []Route53Changes {
if len(cs) <= batchSize {
res := sortChangesByActionNameType(cs)
return []Route53Changes{res}
}
batchChanges := make([]Route53Changes, 0)
changesByOwnership := groupChangesByNameAndOwnershipRelation(cs)
names := make([]string, 0)
for v := range changesByOwnership {
names = append(names, v)
}
sort.Strings(names)
currentBatch := Route53Changes{}
for k, name := range names {
v := changesByOwnership[name]
if len(v) > batchSize {
log.Warnf("Total changes for %v exceeds max batch size of %d, total changes: %d; changes will not be performed", k, batchSize, len(v))
continue
}
if len(currentBatch)+len(v) > batchSize {
// currentBatch would be too large if we add this changeset;
// add currentBatch to batchChanges and start a new currentBatch
batchChanges = append(batchChanges, sortChangesByActionNameType(currentBatch))
currentBatch = append(Route53Changes{}, v...)
} else {
currentBatch = append(currentBatch, v...)
}
}
if len(currentBatch) > 0 {
// add final currentBatch
batchChanges = append(batchChanges, sortChangesByActionNameType(currentBatch))
}
return batchChanges
}
func sortChangesByActionNameType(cs Route53Changes) Route53Changes {
sort.SliceStable(cs, func(i, j int) bool {
if *cs[i].Action > *cs[j].Action {
return true
}
if *cs[i].Action < *cs[j].Action {
return false
}
if *cs[i].ResourceRecordSet.Name < *cs[j].ResourceRecordSet.Name {
return true
}
if *cs[i].ResourceRecordSet.Name > *cs[j].ResourceRecordSet.Name {
return false
}
return *cs[i].ResourceRecordSet.Type < *cs[j].ResourceRecordSet.Type
})
return cs
}
// changesByZone separates a multi-zone change into a single change per zone.
func changesByZone(zones map[string]*route53.HostedZone, changeSet Route53Changes) map[string]Route53Changes {
changes := make(map[string]Route53Changes)
for _, z := range zones {
changes[aws.StringValue(z.Id)] = Route53Changes{}
}
for _, c := range changeSet {
hostname := provider.EnsureTrailingDot(aws.StringValue(c.ResourceRecordSet.Name))
zones := suitableZones(hostname, zones)
if len(zones) == 0 {
log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", c.String())
continue
}
for _, z := range zones {
if c.ResourceRecordSet.AliasTarget != nil && aws.StringValue(c.ResourceRecordSet.AliasTarget.HostedZoneId) == sameZoneAlias {
// alias record is to be created; target needs to be in the same zone as endpoint
// if it's not, this will fail
rrset := *c.ResourceRecordSet
aliasTarget := *rrset.AliasTarget
aliasTarget.HostedZoneId = aws.String(cleanZoneID(aws.StringValue(z.Id)))
rrset.AliasTarget = &aliasTarget
c = &Route53Change{
Change: route53.Change{
Action: c.Action,
ResourceRecordSet: &rrset,
},
}
}
changes[aws.StringValue(z.Id)] = append(changes[aws.StringValue(z.Id)], c)
log.Debugf("Adding %s to zone %s [Id: %s]", hostname, aws.StringValue(z.Name), aws.StringValue(z.Id))
}
}
// separating a change could lead to empty sub changes, remove them here.
for zone, change := range changes {
if len(change) == 0 {
delete(changes, zone)
}
}
return changes
}
// suitableZones returns all suitable private zones and the most suitable public zone
//
// for a given hostname and a set of zones.
func suitableZones(hostname string, zones map[string]*route53.HostedZone) []*route53.HostedZone {
var matchingZones []*route53.HostedZone
var publicZone *route53.HostedZone
for _, z := range zones {
if aws.StringValue(z.Name) == hostname || strings.HasSuffix(hostname, "."+aws.StringValue(z.Name)) {
if z.Config == nil || !aws.BoolValue(z.Config.PrivateZone) {
// Only select the best matching public zone
if publicZone == nil || len(aws.StringValue(z.Name)) > len(aws.StringValue(publicZone.Name)) {
publicZone = z
}
} else {
// Include all private zones
matchingZones = append(matchingZones, z)
}
}
}
if publicZone != nil {
matchingZones = append(matchingZones, publicZone)
}
return matchingZones
}
// useAlias determines if AWS ALIAS should be used.
func useAlias(ep *endpoint.Endpoint, preferCNAME bool) bool {
if preferCNAME {
return false
}
if ep.RecordType == endpoint.RecordTypeCNAME && len(ep.Targets) > 0 {
return canonicalHostedZone(ep.Targets[0]) != ""
}
return false
}
// isAWSAlias determines if a given endpoint is supposed to create an AWS Alias record
// and (if so) returns the target hosted zone ID
func isAWSAlias(ep *endpoint.Endpoint) string {
isAlias, exists := ep.GetProviderSpecificProperty(providerSpecificAlias)
if exists && isAlias == "true" && ep.RecordType == endpoint.RecordTypeCNAME && len(ep.Targets) > 0 {
// alias records can only point to canonical hosted zones (e.g. to ELBs) or other records in the same zone
if hostedZoneID, ok := ep.GetProviderSpecificProperty(providerSpecificTargetHostedZone); ok {
// existing Endpoint where we got the target hosted zone from the Route53 data
return hostedZoneID
}
// check if the target is in a canonical hosted zone
if canonicalHostedZone := canonicalHostedZone(ep.Targets[0]); canonicalHostedZone != "" {
return canonicalHostedZone
}
// if not, target needs to be in the same zone
return sameZoneAlias
}
return ""
}
// canonicalHostedZone returns the matching canonical zone for a given hostname.
func canonicalHostedZone(hostname string) string {
for suffix, zone := range canonicalHostedZones {
if strings.HasSuffix(hostname, suffix) {
return zone
}
}
if strings.HasSuffix(hostname, ".amazonaws.com") {
// hostname is an AWS hostname, but could not find canonical hosted zone.
// This could mean that a new region has been added but is not supported yet.
log.Warnf("Could not find canonical hosted zone for domain %s. This may be because your region is not supported yet.", hostname)
}