diff --git a/main.go b/main.go index 7d32da4a94..4a44877f54 100644 --- a/main.go +++ b/main.go @@ -39,6 +39,7 @@ import ( "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/pkg/apis/externaldns/validation" + "sigs.k8s.io/external-dns/pkg/filters" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/provider/akamai" @@ -186,7 +187,7 @@ func main() { zoneNameFilter := endpoint.NewDomainFilter(cfg.ZoneNameFilter) zoneIDFilter := provider.NewZoneIDFilter(cfg.ZoneIDFilter) zoneTypeFilter := provider.NewZoneTypeFilter(cfg.AWSZoneType) - zoneTagFilter := provider.NewZoneTagFilter(cfg.AWSZoneTagFilter) + zoneTagFilter := filters.NewZoneTagFilter(cfg.AWSZoneTagFilter) var p provider.Provider switch cfg.Provider { diff --git a/pkg/filters/filters.go b/pkg/filters/filters.go new file mode 100644 index 0000000000..4b78584ff5 --- /dev/null +++ b/pkg/filters/filters.go @@ -0,0 +1,27 @@ +/* +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 filters + +import ( + "sigs.k8s.io/external-dns/pkg/filters/zonetagfilter" +) + +var ( + NewZoneTagFilter = zonetagfilter.NewZoneTagFilter +) + +type ZoneTagFilter = zonetagfilter.ZoneTagFilter diff --git a/pkg/filters/zonetagfilter/testdoubles_test.go b/pkg/filters/zonetagfilter/testdoubles_test.go new file mode 100644 index 0000000000..87ec957e2f --- /dev/null +++ b/pkg/filters/zonetagfilter/testdoubles_test.go @@ -0,0 +1,69 @@ +/* +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 zonetagfilter + +import "fmt" + +type filterZoneTags struct { + ZoneTagFilter + inputTags map[string]string +} + +// generateTagFilterAndZoneTagsForMatch generates filter tags and zone tags that do match. +func generateTagFilterAndZoneTagsForMatch(filter, zone int) filterZoneTags { + return generateTagFilterAndZoneTags(filter, zone, true) +} + +// generateTagFilterAndZoneTagsForNotMatch generates filter tags and zone tags that do not match. +func generateTagFilterAndZoneTagsForNotMatch(filter, zone int) filterZoneTags { + return generateTagFilterAndZoneTags(filter, zone, false) +} + +// generateTagFilterAndZoneTags generates filter tags and zone tags based on the match parameter. +func generateTagFilterAndZoneTags(filter, zone int, match bool) filterZoneTags { + validate(filter, zone) + toFilterTags := make([]string, 0, filter) + inputTags := make(map[string]string, zone) + + for i := 0; i < filter; i++ { + tagIndex := i + if !match { + tagIndex += 50 + } + toFilterTags = append(toFilterTags, fmt.Sprintf("tag-%d=value-%d", tagIndex, i)) + } + + for i := 0; i < zone; i++ { + tagIndex := i + if !match { + // Make sure the input tags are different from the filter tags + tagIndex += 2 + } + inputTags[fmt.Sprintf("tag-%d", i)] = fmt.Sprintf("value-%d", tagIndex) + } + + return filterZoneTags{NewZoneTagFilter(toFilterTags), inputTags} +} + +func validate(filter int, zone int) { + if zone > 50 { + panic("zone number is too high") + } + if filter > zone { + panic("filter number is too high") + } +} diff --git a/provider/zone_tag_filter.go b/pkg/filters/zonetagfilter/zone_tag_filter.go similarity index 63% rename from provider/zone_tag_filter.go rename to pkg/filters/zonetagfilter/zone_tag_filter.go index c40ab06e96..f489a0c25c 100644 --- a/provider/zone_tag_filter.go +++ b/pkg/filters/zonetagfilter/zone_tag_filter.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package provider +package zonetagfilter import ( "strings" @@ -22,7 +22,7 @@ import ( // ZoneTagFilter holds a list of zone tags to filter by type ZoneTagFilter struct { - zoneTags []string + tagsMap map[string]string } // NewZoneTagFilter returns a new ZoneTagFilter given a list of zone tags @@ -30,22 +30,30 @@ func NewZoneTagFilter(tags []string) ZoneTagFilter { if len(tags) == 1 && len(tags[0]) == 0 { tags = []string{} } - return ZoneTagFilter{zoneTags: tags} + z := ZoneTagFilter{} + z.tagsMap = make(map[string]string, len(tags)) + // tags pre-processing, to make sure the pre-processing is not happening at the time of filtering + for _, tag := range tags { + parts := strings.SplitN(tag, "=", 2) + key := strings.TrimSpace(parts[0]) + if key == "" { + continue + } + if len(parts) == 2 { + value := strings.TrimSpace(parts[1]) + z.tagsMap[key] = value + } else { + z.tagsMap[key] = "" + } + } + return z } // 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 - } + for key, v := range f.tagsMap { + if value, hasTag := tagsMap[key]; !hasTag || (v != "" && value != v) { + return false } } return true @@ -53,5 +61,5 @@ func (f ZoneTagFilter) Match(tagsMap map[string]string) bool { // IsEmpty returns true if there are no tags for the filter func (f ZoneTagFilter) IsEmpty() bool { - return len(f.zoneTags) == 0 + return len(f.tagsMap) == 0 } diff --git a/pkg/filters/zonetagfilter/zone_tag_filter_test.go b/pkg/filters/zonetagfilter/zone_tag_filter_test.go new file mode 100644 index 0000000000..130f0ebf24 --- /dev/null +++ b/pkg/filters/zonetagfilter/zone_tag_filter_test.go @@ -0,0 +1,153 @@ +/* +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 zonetagfilter + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +var basicZoneTags = []struct { + name string + tagsFilter []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, + }, + { + "empty tag filter matches all", []string{""}, map[string]string{"tag0": "value0"}, true, + }, + { + "tag filter with empty key is ignored", []string{"tag1=value1", "=haha"}, map[string]string{"tag1": "value1"}, true, + }, +} + +func TestZoneTagFilterMatch(t *testing.T) { + for _, tc := range basicZoneTags { + filter := NewZoneTagFilter(tc.tagsFilter) + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.matches, filter.Match(tc.zoneTags)) + }) + } +} + +func TestZoneTagFilterNotSupportedFormat(t *testing.T) { + tests := []struct { + desc string + tags []string + want map[string]string + }{ + {desc: "multiple or separate values with commas", tags: []string{"key1=val1,key2=val2"}, want: map[string]string{"key1": "val1,key2=val2"}}, + {desc: "exclude tag", tags: []string{"!key1"}, want: map[string]string{"!key1": ""}}, + {desc: "exclude tags", tags: []string{"!key1=val"}, want: map[string]string{"!key1": "val"}}, + } + for _, tc := range tests { + t.Run(fmt.Sprintf("%s", tc.desc), func(t *testing.T) { + got := NewZoneTagFilter(tc.tags) + assert.Equal(t, tc.want, got.tagsMap) + }) + } +} + +func TestZoneTagFilterMatchGeneratedValues(t *testing.T) { + tests := []struct { + filters int + zones int + source filterZoneTags + }{ + {10, 30, generateTagFilterAndZoneTagsForMatch(10, 30)}, + {5, 40, generateTagFilterAndZoneTagsForMatch(5, 40)}, + {30, 50, generateTagFilterAndZoneTagsForMatch(30, 50)}, + } + for _, tc := range tests { + t.Run(fmt.Sprintf("filters:%d zones:%d", tc.filters, tc.zones), func(t *testing.T) { + assert.True(t, tc.source.ZoneTagFilter.Match(tc.source.inputTags)) + }) + } +} + +func TestZoneTagFilterNotMatchGeneratedValues(t *testing.T) { + tests := []struct { + filters int + zones int + source filterZoneTags + }{ + {10, 30, generateTagFilterAndZoneTagsForNotMatch(10, 30)}, + {5, 40, generateTagFilterAndZoneTagsForNotMatch(5, 40)}, + {30, 50, generateTagFilterAndZoneTagsForNotMatch(30, 50)}, + } + for _, tc := range tests { + t.Run(fmt.Sprintf("filters:%d zones:%d", tc.filters, tc.zones), func(t *testing.T) { + assert.False(t, tc.source.ZoneTagFilter.Match(tc.source.inputTags)) + }) + } +} + +func BenchmarkZoneTagFilterMatchBasic(b *testing.B) { + for _, tc := range basicZoneTags { + zoneTagFilter := NewZoneTagFilter(tc.tagsFilter) + for range b.N { + zoneTagFilter.Match(tc.zoneTags) + } + } +} + +var benchFixtures = []struct { + source filterZoneTags +}{ + // match + {generateTagFilterAndZoneTagsForMatch(10, 30)}, + {generateTagFilterAndZoneTagsForMatch(5, 40)}, + {generateTagFilterAndZoneTagsForMatch(30, 50)}, + // no match + {generateTagFilterAndZoneTagsForNotMatch(10, 30)}, + {generateTagFilterAndZoneTagsForNotMatch(5, 40)}, + {generateTagFilterAndZoneTagsForNotMatch(30, 50)}, +} + +func BenchmarkZoneTagFilterComplex(b *testing.B) { + for _, tc := range benchFixtures { + for range b.N { + tc.source.ZoneTagFilter.Match(tc.source.inputTags) + } + } +} diff --git a/provider/aws/aws.go b/provider/aws/aws.go index 26ad8112e0..8272d03091 100644 --- a/provider/aws/aws.go +++ b/provider/aws/aws.go @@ -32,6 +32,7 @@ import ( log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/pkg/filters" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) @@ -254,7 +255,7 @@ type AWSProvider struct { // filter hosted zones by type (e.g. private or public) zoneTypeFilter provider.ZoneTypeFilter // filter hosted zones by tags - zoneTagFilter provider.ZoneTagFilter + zoneTagFilter filters.ZoneTagFilter // extend filter for subdomains in the zone (e.g. first.us-east-1.example.com) zoneMatchParent bool preferCNAME bool @@ -268,7 +269,7 @@ type AWSConfig struct { DomainFilter endpoint.DomainFilter ZoneIDFilter provider.ZoneIDFilter ZoneTypeFilter provider.ZoneTypeFilter - ZoneTagFilter provider.ZoneTagFilter + ZoneTagFilter filters.ZoneTagFilter ZoneMatchParent bool BatchChangeSize int BatchChangeSizeBytes int diff --git a/provider/aws/aws_test.go b/provider/aws/aws_test.go index 59ea876f60..b481a24ee9 100644 --- a/provider/aws/aws_test.go +++ b/provider/aws/aws_test.go @@ -37,6 +37,7 @@ import ( "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" + "sigs.k8s.io/external-dns/pkg/filters" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) @@ -325,15 +326,15 @@ func TestAWSZones(t *testing.T) { msg string zoneIDFilter provider.ZoneIDFilter zoneTypeFilter provider.ZoneTypeFilter - zoneTagFilter provider.ZoneTagFilter + zoneTagFilter filters.ZoneTagFilter expectedZones map[string]*route53types.HostedZone }{ - {"no filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{}), allZones}, - {"public filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("public"), provider.NewZoneTagFilter([]string{}), publicZones}, - {"private filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("private"), provider.NewZoneTagFilter([]string{}), privateZones}, - {"unknown filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("unknown"), provider.NewZoneTagFilter([]string{}), noZones}, - {"zone id filter", provider.NewZoneIDFilter([]string{"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{}), privateZones}, - {"tag filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{"zone=3"}), privateZones}, + {"no filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), filters.NewZoneTagFilter([]string{}), allZones}, + {"public filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("public"), filters.NewZoneTagFilter([]string{}), publicZones}, + {"private filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("private"), filters.NewZoneTagFilter([]string{}), privateZones}, + {"unknown filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("unknown"), filters.NewZoneTagFilter([]string{}), noZones}, + {"zone id filter", provider.NewZoneIDFilter([]string{"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneTypeFilter(""), filters.NewZoneTagFilter([]string{}), privateZones}, + {"tag filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), filters.NewZoneTagFilter([]string{"zone=3"}), privateZones}, } { t.Run(ti.msg, func(t *testing.T) { provider, _ := newAWSProviderWithTagFilter(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), ti.zoneIDFilter, ti.zoneTypeFilter, ti.zoneTagFilter, defaultEvaluateTargetHealth, false, nil) @@ -348,7 +349,7 @@ func TestAWSZonesWithTagFilterError(t *testing.T) { client := NewRoute53APIStub(t) provider := &AWSProvider{ clients: map[string]Route53API{defaultAWSProfile: client}, - zoneTagFilter: provider.NewZoneTagFilter([]string{"zone=2"}), + zoneTagFilter: filters.NewZoneTagFilter([]string{"zone=2"}), dryRun: false, zonesCache: &zonesListCache{duration: 1 * time.Minute}, } @@ -2029,10 +2030,10 @@ func listAWSRecords(t *testing.T, client Route53API, zone string) []route53types } func newAWSProvider(t *testing.T, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneTypeFilter provider.ZoneTypeFilter, evaluateTargetHealth, dryRun bool, records []route53types.ResourceRecordSet) (*AWSProvider, *Route53APIStub) { - return newAWSProviderWithTagFilter(t, domainFilter, zoneIDFilter, zoneTypeFilter, provider.NewZoneTagFilter([]string{}), evaluateTargetHealth, dryRun, records) + return newAWSProviderWithTagFilter(t, domainFilter, zoneIDFilter, zoneTypeFilter, filters.NewZoneTagFilter([]string{}), evaluateTargetHealth, dryRun, records) } -func newAWSProviderWithTagFilter(t *testing.T, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneTypeFilter provider.ZoneTypeFilter, zoneTagFilter provider.ZoneTagFilter, evaluateTargetHealth, dryRun bool, records []route53types.ResourceRecordSet) (*AWSProvider, *Route53APIStub) { +func newAWSProviderWithTagFilter(t *testing.T, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneTypeFilter provider.ZoneTypeFilter, zoneTagFilter filters.ZoneTagFilter, evaluateTargetHealth, dryRun bool, records []route53types.ResourceRecordSet) (*AWSProvider, *Route53APIStub) { client := NewRoute53APIStub(t) provider := &AWSProvider{ diff --git a/provider/zone_tag_filter_test.go b/provider/zone_tag_filter_test.go deleted file mode 100644 index 9574e68eb0..0000000000 --- a/provider/zone_tag_filter_test.go +++ /dev/null @@ -1,62 +0,0 @@ -/* -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)) - }) - } -}