Add zone tag filter for AWS

This commit is contained in:
Cesar Wong 2018-12-03 18:16:57 -05:00
parent f25f90db0e
commit 65e13af9b7
7 changed files with 229 additions and 7 deletions

View File

@ -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,

View File

@ -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)

View File

@ -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",

View File

@ -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}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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))
})
}
}