diff --git a/main.go b/main.go index b32b5fd45..56d4e5777 100644 --- a/main.go +++ b/main.go @@ -99,6 +99,7 @@ func main() { domainFilter := provider.NewDomainFilter(cfg.DomainFilter) zoneIDFilter := provider.NewZoneIDFilter(cfg.ZoneIDFilter) zoneTypeFilter := provider.NewZoneTypeFilter(cfg.AWSZoneType) + zoneTagFilter := provider.NewZoneTagFilter(cfg.AWSZoneTagFilter) var p provider.Provider switch cfg.Provider { @@ -110,6 +111,7 @@ func main() { DomainFilter: domainFilter, ZoneIDFilter: zoneIDFilter, ZoneTypeFilter: zoneTypeFilter, + ZoneTagFilter: zoneTagFilter, BatchChangeSize: cfg.AWSBatchChangeSize, BatchChangeInterval: cfg.AWSBatchChangeInterval, EvaluateTargetHealth: cfg.AWSEvaluateTargetHealth, diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 3e67a8bf9..5e50ea374 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -56,6 +56,7 @@ type Config struct { AlibabaCloudConfigFile string AlibabaCloudZoneType string AWSZoneType string + AWSZoneTagFilter []string AWSAssumeRole string AWSBatchChangeSize int AWSBatchChangeInterval time.Duration @@ -127,6 +128,7 @@ var defaultConfig = &Config{ DomainFilter: []string{}, AlibabaCloudConfigFile: "/etc/kubernetes/alibaba-cloud.json", AWSZoneType: "", + AWSZoneTagFilter: []string{}, AWSAssumeRole: "", AWSBatchChangeSize: 4000, AWSBatchChangeInterval: time.Second, @@ -241,6 +243,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("alibaba-cloud-config-file", "When using the Alibaba Cloud provider, specify the Alibaba Cloud configuration file (required when --provider=alibabacloud").Default(defaultConfig.AlibabaCloudConfigFile).StringVar(&cfg.AlibabaCloudConfigFile) app.Flag("alibaba-cloud-zone-type", "When using the Alibaba Cloud provider, filter for zones of this type (optional, options: public, private)").Default(defaultConfig.AlibabaCloudZoneType).EnumVar(&cfg.AlibabaCloudZoneType, "", "public", "private") app.Flag("aws-zone-type", "When using the AWS provider, filter for zones of this type (optional, options: public, private)").Default(defaultConfig.AWSZoneType).EnumVar(&cfg.AWSZoneType, "", "public", "private") + app.Flag("aws-zone-tags", "When using the AWS provider, filter for zones with these tags").Default("").StringsVar(&cfg.AWSZoneTagFilter) app.Flag("aws-assume-role", "When using the AWS provider, assume this IAM role. Useful for hosted zones in another AWS account. Specify the full ARN, e.g. `arn:aws:iam::123455567:role/external-dns` (optional)").Default(defaultConfig.AWSAssumeRole).StringVar(&cfg.AWSAssumeRole) app.Flag("aws-batch-change-size", "When using the AWS provider, set the maximum number of changes that will be applied in each batch.").Default(strconv.Itoa(defaultConfig.AWSBatchChangeSize)).IntVar(&cfg.AWSBatchChangeSize) app.Flag("aws-batch-change-interval", "When using the AWS provider, set the interval between batch changes.").Default(defaultConfig.AWSBatchChangeInterval.String()).DurationVar(&cfg.AWSBatchChangeInterval) diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index 87740640f..bdee0a22d 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -43,6 +43,7 @@ var ( ZoneIDFilter: []string{""}, AlibabaCloudConfigFile: "/etc/kubernetes/alibaba-cloud.json", AWSZoneType: "", + AWSZoneTagFilter: []string{""}, AWSAssumeRole: "", AWSBatchChangeSize: 4000, AWSBatchChangeInterval: time.Second, @@ -94,6 +95,7 @@ var ( ZoneIDFilter: []string{"/hostedzone/ZTST1", "/hostedzone/ZTST2"}, AlibabaCloudConfigFile: "/etc/kubernetes/alibaba-cloud.json", AWSZoneType: "private", + AWSZoneTagFilter: []string{"tag=foo"}, AWSAssumeRole: "some-other-role", AWSBatchChangeSize: 100, AWSBatchChangeInterval: time.Second * 2, @@ -189,6 +191,7 @@ func TestParseFlags(t *testing.T) { "--zone-id-filter=/hostedzone/ZTST1", "--zone-id-filter=/hostedzone/ZTST2", "--aws-zone-type=private", + "--aws-zone-tags=tag=foo", "--aws-assume-role=some-other-role", "--aws-batch-change-size=100", "--aws-batch-change-interval=2s", @@ -248,6 +251,7 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_TLS_CLIENT_CERT_KEY": "/path/to/key.pem", "EXTERNAL_DNS_ZONE_ID_FILTER": "/hostedzone/ZTST1\n/hostedzone/ZTST2", "EXTERNAL_DNS_AWS_ZONE_TYPE": "private", + "EXTERNAL_DNS_AWS_ZONE_TAGS": "tag=foo", "EXTERNAL_DNS_AWS_ASSUME_ROLE": "some-other-role", "EXTERNAL_DNS_AWS_BATCH_CHANGE_SIZE": "100", "EXTERNAL_DNS_AWS_BATCH_CHANGE_INTERVAL": "2s", diff --git a/provider/aws.go b/provider/aws.go index 7ec5e79aa..ec14c8d35 100644 --- a/provider/aws.go +++ b/provider/aws.go @@ -85,6 +85,7 @@ type Route53API interface { ChangeResourceRecordSets(*route53.ChangeResourceRecordSetsInput) (*route53.ChangeResourceRecordSetsOutput, error) CreateHostedZone(*route53.CreateHostedZoneInput) (*route53.CreateHostedZoneOutput, error) ListHostedZonesPages(input *route53.ListHostedZonesInput, fn func(resp *route53.ListHostedZonesOutput, lastPage bool) (shouldContinue bool)) error + ListTagsForResource(input *route53.ListTagsForResourceInput) (*route53.ListTagsForResourceOutput, error) } // AWSProvider is an implementation of Provider for AWS Route53. @@ -100,6 +101,8 @@ type AWSProvider struct { zoneIDFilter ZoneIDFilter // filter hosted zones by type (e.g. private or public) zoneTypeFilter ZoneTypeFilter + // filter hosted zones by tags + zoneTagFilter ZoneTagFilter } // AWSConfig contains configuration to create a new AWS provider. @@ -107,6 +110,7 @@ type AWSConfig struct { DomainFilter DomainFilter ZoneIDFilter ZoneIDFilter ZoneTypeFilter ZoneTypeFilter + ZoneTagFilter ZoneTagFilter BatchChangeSize int BatchChangeInterval time.Duration EvaluateTargetHealth bool @@ -145,6 +149,7 @@ func NewAWSProvider(awsConfig AWSConfig) (*AWSProvider, error) { domainFilter: awsConfig.DomainFilter, zoneIDFilter: awsConfig.ZoneIDFilter, zoneTypeFilter: awsConfig.ZoneTypeFilter, + zoneTagFilter: awsConfig.ZoneTagFilter, batchChangeSize: awsConfig.BatchChangeSize, batchChangeInterval: awsConfig.BatchChangeInterval, evaluateTargetHealth: awsConfig.EvaluateTargetHealth, @@ -158,6 +163,7 @@ func NewAWSProvider(awsConfig AWSConfig) (*AWSProvider, error) { func (p *AWSProvider) Zones() (map[string]*route53.HostedZone, error) { 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)) { @@ -172,6 +178,18 @@ func (p *AWSProvider) Zones() (map[string]*route53.HostedZone, error) { continue } + // Only fetch tags if a tag filter was specified + if !p.zoneTagFilter.IsEmpty() { + tags, err := p.tagsForZone(*zone.Id) + if err != nil { + tagErr = err + return false + } + if !p.zoneTagFilter.Match(tags) { + continue + } + } + zones[aws.StringValue(zone.Id)] = zone } @@ -182,6 +200,9 @@ func (p *AWSProvider) Zones() (map[string]*route53.HostedZone, error) { if err != nil { return nil, err } + if tagErr != nil { + return nil, tagErr + } for _, zone := range zones { log.Debugf("Considering zone: %s (domain: %s)", aws.StringValue(zone.Id), aws.StringValue(zone.Name)) @@ -412,6 +433,21 @@ func (p *AWSProvider) newChange(action string, endpoint *endpoint.Endpoint) *rou return change } +func (p *AWSProvider) tagsForZone(zoneID string) (map[string]string, error) { + response, err := p.client.ListTagsForResource(&route53.ListTagsForResourceInput{ + ResourceType: aws.String("hostedzone"), + ResourceId: aws.String(zoneID), + }) + if err != nil { + return nil, err + } + tagMap := map[string]string{} + for _, tag := range response.ResourceTagSet.Tags { + tagMap[*tag.Key] = *tag.Value + } + return tagMap, nil +} + func batchChangeSet(cs []*route53.Change, batchSize int) [][]*route53.Change { if len(cs) <= batchSize { return [][]*route53.Change{cs} diff --git a/provider/aws_test.go b/provider/aws_test.go index 6852b6df2..8ffbc51ba 100644 --- a/provider/aws_test.go +++ b/provider/aws_test.go @@ -50,6 +50,7 @@ var _ Route53API = &Route53APIStub{} type Route53APIStub struct { zones map[string]*route53.HostedZone recordSets map[string]map[string][]*route53.ResourceRecordSet + zoneTags map[string][]*route53.Tag m dynamicMock } @@ -66,6 +67,7 @@ func NewRoute53APIStub() *Route53APIStub { return &Route53APIStub{ zones: make(map[string]*route53.HostedZone), recordSets: make(map[string]map[string][]*route53.ResourceRecordSet), + zoneTags: make(map[string][]*route53.Tag), } } @@ -95,6 +97,20 @@ func wildcardEscape(s string) string { return s } +func (r *Route53APIStub) ListTagsForResource(input *route53.ListTagsForResourceInput) (*route53.ListTagsForResourceOutput, error) { + if aws.StringValue(input.ResourceType) == "hostedzone" { + tags := r.zoneTags[aws.StringValue(input.ResourceId)] + return &route53.ListTagsForResourceOutput{ + ResourceTagSet: &route53.ResourceTagSet{ + ResourceId: input.ResourceId, + ResourceType: input.ResourceType, + Tags: tags, + }, + }, nil + } + return &route53.ListTagsForResourceOutput{}, nil +} + func (r *Route53APIStub) ChangeResourceRecordSets(input *route53.ChangeResourceRecordSetsInput) (*route53.ChangeResourceRecordSetsOutput, error) { if r.m.isMocked("ChangeResourceRecordSets", input) { return r.m.ChangeResourceRecordSets(input) @@ -231,15 +247,17 @@ func TestAWSZones(t *testing.T) { msg string zoneIDFilter ZoneIDFilter zoneTypeFilter ZoneTypeFilter + zoneTagFilter ZoneTagFilter expectedZones map[string]*route53.HostedZone }{ - {"no filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), allZones}, - {"public filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter("public"), publicZones}, - {"private filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter("private"), privateZones}, - {"unknown filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter("unknown"), noZones}, - {"zone id filter", NewZoneIDFilter([]string{"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."}), NewZoneTypeFilter(""), privateZones}, + {"no filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), NewZoneTagFilter([]string{}), allZones}, + {"public filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter("public"), NewZoneTagFilter([]string{}), publicZones}, + {"private filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter("private"), NewZoneTagFilter([]string{}), privateZones}, + {"unknown filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter("unknown"), NewZoneTagFilter([]string{}), noZones}, + {"zone id filter", NewZoneIDFilter([]string{"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."}), NewZoneTypeFilter(""), NewZoneTagFilter([]string{}), privateZones}, + {"tag filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), NewZoneTagFilter([]string{"zone=3"}), privateZones}, } { - provider, _ := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), ti.zoneIDFilter, ti.zoneTypeFilter, defaultEvaluateTargetHealth, false, []*endpoint.Endpoint{}) + provider, _ := newAWSProviderWithTagFilter(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), ti.zoneIDFilter, ti.zoneTypeFilter, ti.zoneTagFilter, defaultEvaluateTargetHealth, false, []*endpoint.Endpoint{}) zones, err := provider.Zones() require.NoError(t, err) @@ -1027,8 +1045,11 @@ func escapeAWSRecords(t *testing.T, provider *AWSProvider, zone string) { require.NoError(t, err) } } - func newAWSProvider(t *testing.T, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, zoneTypeFilter ZoneTypeFilter, evaluateTargetHealth, dryRun bool, records []*endpoint.Endpoint) (*AWSProvider, *Route53APIStub) { + return newAWSProviderWithTagFilter(t, domainFilter, zoneIDFilter, zoneTypeFilter, NewZoneTagFilter([]string{}), evaluateTargetHealth, dryRun, records) +} + +func newAWSProviderWithTagFilter(t *testing.T, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, zoneTypeFilter ZoneTypeFilter, zoneTagFilter ZoneTagFilter, evaluateTargetHealth, dryRun bool, records []*endpoint.Endpoint) (*AWSProvider, *Route53APIStub) { client := NewRoute53APIStub() provider := &AWSProvider{ @@ -1039,6 +1060,7 @@ func newAWSProvider(t *testing.T, domainFilter DomainFilter, zoneIDFilter ZoneID domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, zoneTypeFilter: zoneTypeFilter, + zoneTagFilter: zoneTagFilter, dryRun: false, } @@ -1067,6 +1089,8 @@ func newAWSProvider(t *testing.T, domainFilter DomainFilter, zoneIDFilter ZoneID Config: &route53.HostedZoneConfig{PrivateZone: aws.Bool(false)}, }) + setupZoneTags(provider.client.(*Route53APIStub)) + setupAWSRecords(t, provider, records) provider.dryRun = dryRun @@ -1074,6 +1098,40 @@ func newAWSProvider(t *testing.T, domainFilter DomainFilter, zoneIDFilter ZoneID return provider, client } +func setupZoneTags(client *Route53APIStub) { + addZoneTags(client.zoneTags, "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.", map[string]string{ + "zone-1-tag-1": "tag-1-value", + "domain": "test-2", + "zone": "1", + }) + addZoneTags(client.zoneTags, "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.", map[string]string{ + "zone-2-tag-1": "tag-1-value", + "domain": "test-2", + "zone": "2", + }) + addZoneTags(client.zoneTags, "/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do.", map[string]string{ + "zone-3-tag-1": "tag-1-value", + "domain": "test-2", + "zone": "3", + }) + addZoneTags(client.zoneTags, "/hostedzone/zone-4.ext-dns-test-2.teapot.zalan.do.", map[string]string{ + "zone-4-tag-1": "tag-1-value", + "domain": "test-3", + "zone": "4", + }) +} + +func addZoneTags(tagMap map[string][]*route53.Tag, zoneID string, tags map[string]string) { + tagList := make([]*route53.Tag, 0, len(tags)) + for k, v := range tags { + tagList = append(tagList, &route53.Tag{ + Key: aws.String(k), + Value: aws.String(v), + }) + } + tagMap[zoneID] = tagList +} + func validateRecords(t *testing.T, records []*route53.ResourceRecordSet, expected []*route53.ResourceRecordSet) { assert.Equal(t, expected, records) } diff --git a/provider/zone_tag_filter.go b/provider/zone_tag_filter.go new file mode 100644 index 000000000..c40ab06e9 --- /dev/null +++ b/provider/zone_tag_filter.go @@ -0,0 +1,57 @@ +/* +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 provider + +import ( + "strings" +) + +// ZoneTagFilter holds a list of zone tags to filter by +type ZoneTagFilter struct { + zoneTags []string +} + +// NewZoneTagFilter returns a new ZoneTagFilter given a list of zone tags +func NewZoneTagFilter(tags []string) ZoneTagFilter { + if len(tags) == 1 && len(tags[0]) == 0 { + tags = []string{} + } + return ZoneTagFilter{zoneTags: tags} +} + +// Match checks whether a zone's set of tags matches the provided tag values +func (f ZoneTagFilter) Match(tagsMap map[string]string) bool { + for _, tagFilter := range f.zoneTags { + filterParts := strings.SplitN(tagFilter, "=", 2) + switch len(filterParts) { + case 1: + if _, hasTag := tagsMap[filterParts[0]]; !hasTag { + return false + } + case 2: + if value, hasTag := tagsMap[filterParts[0]]; !hasTag || value != filterParts[1] { + return false + } + } + } + return true +} + +// IsEmpty returns true if there are no tags for the filter +func (f ZoneTagFilter) IsEmpty() bool { + return len(f.zoneTags) == 0 +} diff --git a/provider/zone_tag_filter_test.go b/provider/zone_tag_filter_test.go new file mode 100644 index 000000000..9574e68eb --- /dev/null +++ b/provider/zone_tag_filter_test.go @@ -0,0 +1,62 @@ +/* +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 provider + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestZoneTagFilterMatch(t *testing.T) { + for _, tc := range []struct { + name string + zoneTagFilter []string + zoneTags map[string]string + matches bool + }{ + { + "single tag no match", []string{"tag1=value1"}, map[string]string{"tag0": "value0"}, false, + }, + { + "single tag matches", []string{"tag1=value1"}, map[string]string{"tag1": "value1"}, true, + }, + { + "multiple tags no value match", []string{"tag1=value1"}, map[string]string{"tag0": "value0", "tag1": "value2"}, false, + }, + { + "multiple tags matches", []string{"tag1=value1"}, map[string]string{"tag0": "value0", "tag1": "value1"}, true, + }, + { + "tag name no match", []string{"tag1"}, map[string]string{"tag0": "value0"}, false, + }, + { + "tag name matches", []string{"tag1"}, map[string]string{"tag1": "value1"}, true, + }, + { + "multiple filter no match", []string{"tag1=value1", "tag2=value2"}, map[string]string{"tag1": "value1"}, false, + }, + { + "multiple filter matches", []string{"tag1=value1", "tag2=value2"}, map[string]string{"tag2": "value2", "tag1": "value1", "tag3": "value3"}, true, + }, + } { + zoneTagFilter := NewZoneTagFilter(tc.zoneTagFilter) + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.matches, zoneTagFilter.Match(tc.zoneTags)) + }) + } +}