diff --git a/api/v1alpha1/dnsrecord_types.go b/api/v1alpha1/dnsrecord_types.go index 63228984..7ba3cb56 100644 --- a/api/v1alpha1/dnsrecord_types.go +++ b/api/v1alpha1/dnsrecord_types.go @@ -25,6 +25,8 @@ import ( externaldns "sigs.k8s.io/external-dns/endpoint" externaldnsprovider "sigs.k8s.io/external-dns/provider" externaldnsregistry "sigs.k8s.io/external-dns/registry" + + "github.com/kuadrant/dns-operator/internal/external-dns/registry" ) // DNSRecordSpec defines the desired state of DNSRecord @@ -154,7 +156,7 @@ func (s *DNSRecord) Validate() error { func (s *DNSRecord) GetRegistry(provider externaldnsprovider.Provider, managedDNSRecordTypes, excludeDNSRecordTypes []string) (externaldnsregistry.Registry, error) { if s.Spec.OwnerID != nil { - return externaldnsregistry.NewTXTRegistry(provider, txtRegistryPrefix, txtRegistrySuffix, *s.Spec.OwnerID, txtRegistryCacheInterval, + return registry.NewTXTRegistry(provider, txtRegistryPrefix, txtRegistrySuffix, *s.Spec.OwnerID, txtRegistryCacheInterval, txtRegistryWildcardReplacement, managedDNSRecordTypes, excludeDNSRecordTypes, txtRegistryEncryptEnabled, []byte(txtRegistryEncryptAESKey)) } else { return externaldnsregistry.NewNoopRegistry(provider) diff --git a/go.mod b/go.mod index e29a4146..366efa54 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,13 @@ go 1.21 require ( github.com/aws/aws-sdk-go v1.44.311 github.com/go-logr/logr v1.3.0 + github.com/google/uuid v1.3.1 github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e github.com/onsi/ginkgo/v2 v2.13.2 github.com/onsi/gomega v1.30.0 github.com/prometheus/client_golang v1.17.0 + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.8.4 google.golang.org/api v0.134.0 k8s.io/api v0.28.3 k8s.io/apimachinery v0.28.3 @@ -48,7 +51,6 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20221212185716-aee1124e3a93 // indirect github.com/google/s2a-go v0.1.4 // indirect - github.com/google/uuid v1.3.1 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/imdario/mergo v0.3.16 // indirect @@ -64,11 +66,11 @@ require ( github.com/openshift/api v0.0.0-20230607130528-611114dca681 // indirect github.com/openshift/client-go v0.0.0-20230607134213-3cd0021bbee3 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/projectcontour/contour v1.25.2 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.5 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -104,8 +106,6 @@ require ( sigs.k8s.io/yaml v1.4.0 // indirect ) -replace sigs.k8s.io/external-dns => github.com/kuadrant/external-dns v0.0.0-20240315162317-073094ed9bea - // To Update with changes from v0.14.0_kuadrant run: // go mod edit --replace sigs.k8s.io/external-dns=github.com/kuadrant/external-dns@v0.14.0_kuadrant // go mod tidy diff --git a/go.sum b/go.sum index df1aa9d1..b8b48dc6 100644 --- a/go.sum +++ b/go.sum @@ -482,8 +482,6 @@ github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kuadrant/external-dns v0.0.0-20240315162317-073094ed9bea h1:Ob3/nd2gCnlM1aa6YyKezQlmcUnBKT6zsS4l7FP7j6E= -github.com/kuadrant/external-dns v0.0.0-20240315162317-073094ed9bea/go.mod h1:d4Knr/BFz8U1Lc6yLhCzTRP6nJOz6fqR/MnqqJPcIlU= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= @@ -1171,6 +1169,8 @@ sigs.k8s.io/controller-runtime v0.6.1/go.mod h1:XRYBPdbf5XJu9kpS84VJiZ7h/u1hF3gE sigs.k8s.io/controller-runtime v0.16.3 h1:2TuvuokmfXvDUamSx1SuAOO3eTyye+47mJCigwG62c4= sigs.k8s.io/controller-runtime v0.16.3/go.mod h1:j7bialYoSn142nv9sCOJmQgDXQXxnroFU4VnX/brVJ0= sigs.k8s.io/controller-tools v0.3.1-0.20200517180335-820a4a27ea84/go.mod h1:enhtKGfxZD1GFEoMgP8Fdbu+uKQ/cq1/WGJhdVChfvI= +sigs.k8s.io/external-dns v0.14.0 h1:pgY3DdyoBei+ej1nyZUzRt9ECm9RRwb9s6/CPWe51tc= +sigs.k8s.io/external-dns v0.14.0/go.mod h1:d4Knr/BFz8U1Lc6yLhCzTRP6nJOz6fqR/MnqqJPcIlU= sigs.k8s.io/gateway-api v0.7.1 h1:Tts2jeepVkPA5rVG/iO+S43s9n7Vp7jCDhZDQYtPigQ= sigs.k8s.io/gateway-api v0.7.1/go.mod h1:Xv0+ZMxX0lu1nSSDIIPEfbVztgNZ+3cfiYrJsa2Ooso= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= diff --git a/internal/controller/dnsrecord_controller.go b/internal/controller/dnsrecord_controller.go index edc00af6..6976511a 100644 --- a/internal/controller/dnsrecord_controller.go +++ b/internal/controller/dnsrecord_controller.go @@ -33,11 +33,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" externaldnsendpoint "sigs.k8s.io/external-dns/endpoint" - externaldnsplan "sigs.k8s.io/external-dns/plan" externaldnsprovider "sigs.k8s.io/external-dns/provider" "github.com/kuadrant/dns-operator/api/v1alpha1" "github.com/kuadrant/dns-operator/internal/common/conditions" + externaldnsplan "github.com/kuadrant/dns-operator/internal/external-dns/plan" "github.com/kuadrant/dns-operator/internal/provider" ) diff --git a/internal/external-dns/plan/conflict.go b/internal/external-dns/plan/conflict.go new file mode 100644 index 00000000..3044059b --- /dev/null +++ b/internal/external-dns/plan/conflict.go @@ -0,0 +1,125 @@ +/* +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 plan + +import ( + "sort" + + log "github.com/sirupsen/logrus" + + "sigs.k8s.io/external-dns/endpoint" +) + +// ConflictResolver is used to make a decision in case of two or more different kubernetes resources +// are trying to acquire same DNS name +type ConflictResolver interface { + ResolveCreate(candidates []*endpoint.Endpoint) *endpoint.Endpoint + ResolveUpdate(current *endpoint.Endpoint, candidates []*endpoint.Endpoint) *endpoint.Endpoint + ResolveRecordTypes(key planKey, row *planTableRow) map[string]*domainEndpoints +} + +// PerResource allows only one resource to own a given dns name +type PerResource struct{} + +// ResolveCreate is invoked when dns name is not owned by any resource +// ResolveCreate takes "minimal" (string comparison of Target) endpoint to acquire the DNS record +func (s PerResource) ResolveCreate(candidates []*endpoint.Endpoint) *endpoint.Endpoint { + var min *endpoint.Endpoint + for _, ep := range candidates { + if min == nil || s.less(ep, min) { + min = ep + } + } + return min +} + +// ResolveUpdate is invoked when dns name is already owned by "current" endpoint +// ResolveUpdate uses "current" record as base and updates it accordingly with new version of same resource +// if it doesn't exist then pick min +func (s PerResource) ResolveUpdate(current *endpoint.Endpoint, candidates []*endpoint.Endpoint) *endpoint.Endpoint { + currentResource := current.Labels[endpoint.ResourceLabelKey] // resource which has already acquired the DNS + // TODO: sort candidates only needed because we can still have two endpoints from same resource here. We sort for consistency + // TODO: remove once single endpoint can have multiple targets + sort.SliceStable(candidates, func(i, j int) bool { + return s.less(candidates[i], candidates[j]) + }) + for _, ep := range candidates { + if ep.Labels[endpoint.ResourceLabelKey] == currentResource { + return ep + } + } + return s.ResolveCreate(candidates) +} + +// ResolveRecordTypes attempts to detect and resolve record type conflicts in desired +// endpoints for a domain. For eample if the there is more than 1 candidate and at lease one +// of them is a CNAME. Per [RFC 1034 3.6.2] domains that contain a CNAME can not contain any +// other record types. The default policy will prefer A and AAAA record types when a conflict is +// detected (consistent with [endpoint.Targets.Less]). +// +// [RFC 1034 3.6.2]: https://datatracker.ietf.org/doc/html/rfc1034#autoid-15 +func (s PerResource) ResolveRecordTypes(key planKey, row *planTableRow) map[string]*domainEndpoints { + // no conflicts if only a single desired record type for the domain + if len(row.candidates) <= 1 { + return row.records + } + + cname := false + other := false + for _, c := range row.candidates { + if c.RecordType == endpoint.RecordTypeCNAME { + cname = true + } else { + other = true + } + + if cname && other { + break + } + } + + // conflict was found, remove candiates of non-preferred record types + if cname && other { + log.Infof("Domain %s contains conflicting record type candidates; discarding CNAME record", key.dnsName) + records := map[string]*domainEndpoints{} + for recordType, recs := range row.records { + // policy is to prefer the non-CNAME record types when a conflict is found + if recordType == endpoint.RecordTypeCNAME { + // discard candidates of conflicting records + // keep currect so they can be deleted + records[recordType] = &domainEndpoints{ + current: recs.current, + candidates: []*endpoint.Endpoint{}, + } + } else { + records[recordType] = recs + } + } + + return records + } + + // no conflict, return all records types + return row.records +} + +// less returns true if endpoint x is less than y +func (s PerResource) less(x, y *endpoint.Endpoint) bool { + return x.Targets.IsLess(y.Targets) +} + +// TODO: with cross-resource/cross-cluster setup alternative variations of ConflictResolver can be used diff --git a/internal/external-dns/plan/conflict_test.go b/internal/external-dns/plan/conflict_test.go new file mode 100644 index 00000000..d0ed808c --- /dev/null +++ b/internal/external-dns/plan/conflict_test.go @@ -0,0 +1,298 @@ +/* +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 plan + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/suite" + + "sigs.k8s.io/external-dns/endpoint" +) + +var _ ConflictResolver = PerResource{} + +type ResolverSuite struct { + // resolvers + perResource PerResource + // endpoints + fooV1Cname *endpoint.Endpoint + fooV2Cname *endpoint.Endpoint + fooV2CnameDuplicate *endpoint.Endpoint + fooA5 *endpoint.Endpoint + fooAAAA5 *endpoint.Endpoint + bar127A *endpoint.Endpoint + bar192A *endpoint.Endpoint + bar127AAnother *endpoint.Endpoint + legacyBar192A *endpoint.Endpoint // record created in AWS now without resource label + suite.Suite +} + +func (suite *ResolverSuite) SetupTest() { + suite.perResource = PerResource{} + // initialize endpoints used in tests + suite.fooV1Cname = &endpoint.Endpoint{ + DNSName: "foo", + Targets: endpoint.Targets{"v1"}, + RecordType: "CNAME", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/foo-v1", + }, + } + suite.fooV2Cname = &endpoint.Endpoint{ + DNSName: "foo", + Targets: endpoint.Targets{"v2"}, + RecordType: "CNAME", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/foo-v2", + }, + } + suite.fooV2CnameDuplicate = &endpoint.Endpoint{ + DNSName: "foo", + Targets: endpoint.Targets{"v2"}, + RecordType: "CNAME", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/foo-v2-duplicate", + }, + } + suite.fooA5 = &endpoint.Endpoint{ + DNSName: "foo", + Targets: endpoint.Targets{"5.5.5.5"}, + RecordType: "A", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/foo-5", + }, + } + suite.fooAAAA5 = &endpoint.Endpoint{ + DNSName: "foo", + Targets: endpoint.Targets{"2001:DB8::1"}, + RecordType: "AAAA", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/foo-5", + }, + } + suite.bar127A = &endpoint.Endpoint{ + DNSName: "bar", + Targets: endpoint.Targets{"127.0.0.1"}, + RecordType: "A", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/bar-127", + }, + } + suite.bar127AAnother = &endpoint.Endpoint{ // TODO: remove this once we move to multiple targets under same endpoint + DNSName: "bar", + Targets: endpoint.Targets{"8.8.8.8"}, + RecordType: "A", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/bar-127", + }, + } + suite.bar192A = &endpoint.Endpoint{ + DNSName: "bar", + Targets: endpoint.Targets{"192.168.0.1"}, + RecordType: "A", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/bar-192", + }, + } + suite.legacyBar192A = &endpoint.Endpoint{ + DNSName: "bar", + Targets: endpoint.Targets{"192.168.0.1"}, + RecordType: "A", + } +} + +func (suite *ResolverSuite) TestStrictResolver() { + // test that perResource resolver picks min for create list + suite.Equal(suite.bar127A, suite.perResource.ResolveCreate([]*endpoint.Endpoint{suite.bar127A, suite.bar192A}), "should pick min one") + suite.Equal(suite.fooA5, suite.perResource.ResolveCreate([]*endpoint.Endpoint{suite.fooA5, suite.fooV1Cname}), "should pick min one") + suite.Equal(suite.fooV1Cname, suite.perResource.ResolveCreate([]*endpoint.Endpoint{suite.fooV2Cname, suite.fooV1Cname}), "should pick min one") + + // test that perResource resolver preserves resource if it still exists + suite.Equal(suite.bar127AAnother, suite.perResource.ResolveUpdate(suite.bar127A, []*endpoint.Endpoint{suite.bar127AAnother, suite.bar127A}), "should pick min for update when same resource endpoint occurs multiple times (remove after multiple-target support") // TODO:remove this test + suite.Equal(suite.bar127A, suite.perResource.ResolveUpdate(suite.bar127A, []*endpoint.Endpoint{suite.bar192A, suite.bar127A}), "should pick existing resource") + suite.Equal(suite.fooV2Cname, suite.perResource.ResolveUpdate(suite.fooV2Cname, []*endpoint.Endpoint{suite.fooV2Cname, suite.fooV2CnameDuplicate}), "should pick existing resource even if targets are same") + suite.Equal(suite.fooA5, suite.perResource.ResolveUpdate(suite.fooV1Cname, []*endpoint.Endpoint{suite.fooA5, suite.fooV2Cname}), "should pick new if resource was deleted") + // should actually get the updated record (note ttl is different) + newFooV1Cname := &endpoint.Endpoint{ + DNSName: suite.fooV1Cname.DNSName, + Targets: suite.fooV1Cname.Targets, + Labels: suite.fooV1Cname.Labels, + RecordType: suite.fooV1Cname.RecordType, + RecordTTL: suite.fooV1Cname.RecordTTL + 1, // ttl is different + } + suite.Equal(newFooV1Cname, suite.perResource.ResolveUpdate(suite.fooV1Cname, []*endpoint.Endpoint{suite.fooA5, suite.fooV2Cname, newFooV1Cname}), "should actually pick same resource with updates") + + // legacy record's resource value will not match any candidates resource label + // therefore pick minimum again + suite.Equal(suite.bar127A, suite.perResource.ResolveUpdate(suite.legacyBar192A, []*endpoint.Endpoint{suite.bar127A, suite.bar192A}), " legacy record's resource value will not match, should pick minimum") +} + +func (suite *ResolverSuite) TestPerResource_ResolveRecordTypes() { + type args struct { + key planKey + row *planTableRow + } + tests := []struct { + name string + args args + want map[string]*domainEndpoints + }{ + { + name: "no conflict: cname record", + args: args{ + key: planKey{dnsName: "foo"}, + row: &planTableRow{ + candidates: []*endpoint.Endpoint{suite.fooV1Cname}, + records: map[string]*domainEndpoints{ + endpoint.RecordTypeCNAME: { + candidates: []*endpoint.Endpoint{suite.fooV1Cname}, + }, + }, + }, + }, + want: map[string]*domainEndpoints{ + endpoint.RecordTypeCNAME: { + candidates: []*endpoint.Endpoint{suite.fooV1Cname}, + }, + }, + }, + { + name: "no conflict: a record", + args: args{ + key: planKey{dnsName: "foo"}, + row: &planTableRow{ + current: []*endpoint.Endpoint{suite.fooA5}, + candidates: []*endpoint.Endpoint{suite.fooA5}, + records: map[string]*domainEndpoints{ + endpoint.RecordTypeA: { + current: suite.fooA5, + candidates: []*endpoint.Endpoint{suite.fooA5}, + }, + }, + }, + }, + want: map[string]*domainEndpoints{ + endpoint.RecordTypeA: { + current: suite.fooA5, + candidates: []*endpoint.Endpoint{suite.fooA5}, + }, + }, + }, + { + name: "no conflict: a and aaaa records", + args: args{ + key: planKey{dnsName: "foo"}, + row: &planTableRow{ + candidates: []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA5}, + records: map[string]*domainEndpoints{ + endpoint.RecordTypeA: { + candidates: []*endpoint.Endpoint{suite.fooA5}, + }, + endpoint.RecordTypeAAAA: { + candidates: []*endpoint.Endpoint{suite.fooAAAA5}, + }, + }, + }, + }, + want: map[string]*domainEndpoints{ + endpoint.RecordTypeA: { + candidates: []*endpoint.Endpoint{suite.fooA5}, + }, + endpoint.RecordTypeAAAA: { + candidates: []*endpoint.Endpoint{suite.fooAAAA5}, + }, + }, + }, + { + name: "conflict: cname and a records", + args: args{ + key: planKey{dnsName: "foo"}, + row: &planTableRow{ + current: []*endpoint.Endpoint{suite.fooV1Cname}, + candidates: []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5}, + records: map[string]*domainEndpoints{ + endpoint.RecordTypeCNAME: { + current: suite.fooV1Cname, + candidates: []*endpoint.Endpoint{suite.fooV1Cname}, + }, + endpoint.RecordTypeA: { + candidates: []*endpoint.Endpoint{suite.fooA5}, + }, + }, + }, + }, + want: map[string]*domainEndpoints{ + endpoint.RecordTypeCNAME: { + current: suite.fooV1Cname, + candidates: []*endpoint.Endpoint{}, + }, + endpoint.RecordTypeA: { + candidates: []*endpoint.Endpoint{suite.fooA5}, + }, + }, + }, + { + name: "conflict: cname, a, and aaaa records", + args: args{ + key: planKey{dnsName: "foo"}, + row: &planTableRow{ + current: []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA5}, + candidates: []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5, suite.fooAAAA5}, + records: map[string]*domainEndpoints{ + endpoint.RecordTypeCNAME: { + candidates: []*endpoint.Endpoint{suite.fooV1Cname}, + }, + endpoint.RecordTypeA: { + current: suite.fooA5, + candidates: []*endpoint.Endpoint{suite.fooA5}, + }, + endpoint.RecordTypeAAAA: { + current: suite.fooAAAA5, + candidates: []*endpoint.Endpoint{suite.fooAAAA5}, + }, + }, + }, + }, + want: map[string]*domainEndpoints{ + endpoint.RecordTypeCNAME: { + candidates: []*endpoint.Endpoint{}, + }, + endpoint.RecordTypeA: { + current: suite.fooA5, + candidates: []*endpoint.Endpoint{suite.fooA5}, + }, + endpoint.RecordTypeAAAA: { + current: suite.fooAAAA5, + candidates: []*endpoint.Endpoint{suite.fooAAAA5}, + }, + }, + }, + } + for _, tt := range tests { + suite.Run(tt.name, func() { + if got := suite.perResource.ResolveRecordTypes(tt.args.key, tt.args.row); !reflect.DeepEqual(got, tt.want) { + suite.T().Errorf("PerResource.ResolveRecordTypes() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConflictResolver(t *testing.T) { + suite.Run(t, new(ResolverSuite)) +} diff --git a/internal/external-dns/plan/plan.go b/internal/external-dns/plan/plan.go new file mode 100644 index 00000000..086020c9 --- /dev/null +++ b/internal/external-dns/plan/plan.go @@ -0,0 +1,363 @@ +/* +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 plan + +import ( + "fmt" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" + + "sigs.k8s.io/external-dns/endpoint" + externaldnsplan "sigs.k8s.io/external-dns/plan" +) + +// PropertyComparator is used in Plan for comparing the previous and current custom annotations. +type PropertyComparator func(name string, previous string, current string) bool + +// Plan can convert a list of desired and current records to a series of create, +// update and delete actions. +type Plan struct { + // List of current records + Current []*endpoint.Endpoint + // List of desired records + Desired []*endpoint.Endpoint + // Policies under which the desired changes are calculated + Policies []Policy + // List of changes necessary to move towards desired state + // Populated after calling Calculate() + Changes *externaldnsplan.Changes + // DomainFilter matches DNS names + DomainFilter endpoint.MatchAllDomainFilters + // ManagedRecords are DNS record types that will be considered for management. + ManagedRecords []string + // ExcludeRecords are DNS record types that will be excluded from management. + ExcludeRecords []string + // OwnerID of records to manage + OwnerID string +} + +// planKey is a key for a row in `planTable`. +type planKey struct { + dnsName string + setIdentifier string +} + +// planTable is a supplementary struct for Plan +// each row correspond to a planKey -> (current records + all desired records) +// +// planTable (-> = target) +// -------------------------------------------------------------- +// DNSName | Current record | Desired Records | +// -------------------------------------------------------------- +// foo.com | [->1.1.1.1 ] | [->1.1.1.1] | = no action +// -------------------------------------------------------------- +// bar.com | | [->191.1.1.1, ->190.1.1.1] | = create (bar.com [-> 190.1.1.1]) +// -------------------------------------------------------------- +// dog.com | [->1.1.1.2] | | = delete (dog.com [-> 1.1.1.2]) +// -------------------------------------------------------------- +// cat.com | [->::1, ->1.1.1.3] | [->1.1.1.3] | = update old (cat.com [-> ::1, -> 1.1.1.3]) new (cat.com [-> 1.1.1.3]) +// -------------------------------------------------------------- +// big.com | [->1.1.1.4] | [->ing.elb.com] | = update old (big.com [-> 1.1.1.4]) new (big.com [-> ing.elb.com]) +// -------------------------------------------------------------- +// "=", i.e. result of calculation relies on supplied ConflictResolver +type planTable struct { + rows map[planKey]*planTableRow + resolver ConflictResolver +} + +func newPlanTable() planTable { // TODO: make resolver configurable + return planTable{map[planKey]*planTableRow{}, PerResource{}} +} + +// planTableRow represents a set of current and desired domain resource records. +type planTableRow struct { + // current corresponds to the records currently occupying dns name on the dns provider. More than one record may + // be represented here: for example A and AAAA. If the current domain record is a CNAME, no other record types + // are allowed per [RFC 1034 3.6.2] + // + // [RFC 1034 3.6.2]: https://datatracker.ietf.org/doc/html/rfc1034#autoid-15 + current []*endpoint.Endpoint + // candidates corresponds to the list of records which would like to have this dnsName. + candidates []*endpoint.Endpoint + // records is a grouping of current and candidates by record type, for example A, AAAA, CNAME. + records map[string]*domainEndpoints +} + +// domainEndpoints is a grouping of current, which are existing records from the registry, and candidates, +// which are desired records from the source. All records in this grouping have the same record type. +type domainEndpoints struct { + // current corresponds to existing record from the registry. Maybe nil if no current record of the type exists. + current *endpoint.Endpoint + // candidates corresponds to the list of records which would like to have this dnsName. + candidates []*endpoint.Endpoint +} + +func (t planTableRow) String() string { + return fmt.Sprintf("planTableRow{current=%v, candidates=%v}", t.current, t.candidates) +} + +func (t planTable) addCurrent(e *endpoint.Endpoint) { + key := t.newPlanKey(e) + t.rows[key].current = append(t.rows[key].current, e) + t.rows[key].records[e.RecordType].current = e +} + +func (t planTable) addCandidate(e *endpoint.Endpoint) { + key := t.newPlanKey(e) + t.rows[key].candidates = append(t.rows[key].candidates, e) + t.rows[key].records[e.RecordType].candidates = append(t.rows[key].records[e.RecordType].candidates, e) +} + +func (t *planTable) newPlanKey(e *endpoint.Endpoint) planKey { + key := planKey{ + dnsName: normalizeDNSName(e.DNSName), + setIdentifier: e.SetIdentifier, + } + + if _, ok := t.rows[key]; !ok { + t.rows[key] = &planTableRow{ + records: make(map[string]*domainEndpoints), + } + } + + if _, ok := t.rows[key].records[e.RecordType]; !ok { + t.rows[key].records[e.RecordType] = &domainEndpoints{} + } + + return key +} + +// Calculate computes the actions needed to move current state towards desired +// state. It then passes those changes to the current policy for further +// processing. It returns a copy of Plan with the changes populated. +func (p *Plan) Calculate() *Plan { + t := newPlanTable() + + if p.DomainFilter == nil { + p.DomainFilter = endpoint.MatchAllDomainFilters(nil) + } + + for _, current := range filterRecordsForPlan(p.Current, p.DomainFilter, p.ManagedRecords, p.ExcludeRecords) { + t.addCurrent(current) + } + for _, desired := range filterRecordsForPlan(p.Desired, p.DomainFilter, p.ManagedRecords, p.ExcludeRecords) { + t.addCandidate(desired) + } + + changes := &externaldnsplan.Changes{} + + for key, row := range t.rows { + // dns name not taken + if len(row.current) == 0 { + recordsByType := t.resolver.ResolveRecordTypes(key, row) + for _, records := range recordsByType { + if len(records.candidates) > 0 { + changes.Create = append(changes.Create, t.resolver.ResolveCreate(records.candidates)) + } + } + } + + // dns name released or possibly owned by a different external dns + if len(row.current) > 0 && len(row.candidates) == 0 { + changes.Delete = append(changes.Delete, row.current...) + } + + // dns name is taken + if len(row.current) > 0 && len(row.candidates) > 0 { + creates := []*endpoint.Endpoint{} + + // apply changes for each record type + recordsByType := t.resolver.ResolveRecordTypes(key, row) + for _, records := range recordsByType { + // record type not desired + if records.current != nil && len(records.candidates) == 0 { + changes.Delete = append(changes.Delete, records.current) + } + + // new record type desired + if records.current == nil && len(records.candidates) > 0 { + update := t.resolver.ResolveCreate(records.candidates) + // creates are evaluated after all domain records have been processed to + // validate that this external dns has ownership claim on the domain before + // adding the records to planned changes. + creates = append(creates, update) + } + + // update existing record + if records.current != nil && len(records.candidates) > 0 { + update := t.resolver.ResolveUpdate(records.current, records.candidates) + + if shouldUpdateTTL(update, records.current) || targetChanged(update, records.current) || p.shouldUpdateProviderSpecific(update, records.current) { + inheritOwner(records.current, update) + changes.UpdateNew = append(changes.UpdateNew, update) + changes.UpdateOld = append(changes.UpdateOld, records.current) + } + } + } + + if len(creates) > 0 { + // only add creates if the external dns has ownership claim on the domain + ownersMatch := true + for _, current := range row.current { + if p.OwnerID != "" && !current.IsOwnedBy(p.OwnerID) { + ownersMatch = false + } + } + + if ownersMatch { + changes.Create = append(changes.Create, creates...) + } + } + } + } + + for _, pol := range p.Policies { + changes = pol.Apply(changes) + } + + // filter out updates this external dns does not have ownership claim over + if p.OwnerID != "" { + changes.Delete = endpoint.FilterEndpointsByOwnerID(p.OwnerID, changes.Delete) + changes.UpdateOld = endpoint.FilterEndpointsByOwnerID(p.OwnerID, changes.UpdateOld) + changes.UpdateNew = endpoint.FilterEndpointsByOwnerID(p.OwnerID, changes.UpdateNew) + } + + plan := &Plan{ + Current: p.Current, + Desired: p.Desired, + Changes: changes, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + } + + return plan +} + +func inheritOwner(from, to *endpoint.Endpoint) { + if to.Labels == nil { + to.Labels = map[string]string{} + } + if from.Labels == nil { + from.Labels = map[string]string{} + } + to.Labels[endpoint.OwnerLabelKey] = from.Labels[endpoint.OwnerLabelKey] +} + +func targetChanged(desired, current *endpoint.Endpoint) bool { + return !desired.Targets.Same(current.Targets) +} + +func shouldUpdateTTL(desired, current *endpoint.Endpoint) bool { + if !desired.RecordTTL.IsConfigured() { + return false + } + return desired.RecordTTL != current.RecordTTL +} + +func (p *Plan) shouldUpdateProviderSpecific(desired, current *endpoint.Endpoint) bool { + desiredProperties := map[string]endpoint.ProviderSpecificProperty{} + + for _, d := range desired.ProviderSpecific { + desiredProperties[d.Name] = d + } + for _, c := range current.ProviderSpecific { + if d, ok := desiredProperties[c.Name]; ok { + if c.Value != d.Value { + return true + } + delete(desiredProperties, c.Name) + } else { + return true + } + } + + return len(desiredProperties) > 0 +} + +// filterRecordsForPlan removes records that are not relevant to the planner. +// Currently this just removes TXT records to prevent them from being +// deleted erroneously by the planner (only the TXT registry should do this.) +// +// Per RFC 1034, CNAME records conflict with all other records - it is the +// only record with this property. The behavior of the planner may need to be +// made more sophisticated to codify this. +func filterRecordsForPlan(records []*endpoint.Endpoint, domainFilter endpoint.MatchAllDomainFilters, managedRecords, excludeRecords []string) []*endpoint.Endpoint { + filtered := []*endpoint.Endpoint{} + + for _, record := range records { + // Ignore records that do not match the domain filter provided + if !domainFilter.Match(record.DNSName) { + log.Debugf("ignoring record %s that does not match domain filter", record.DNSName) + continue + } + if IsManagedRecord(record.RecordType, managedRecords, excludeRecords) { + filtered = append(filtered, record) + } + } + + return filtered +} + +// normalizeDNSName converts a DNS name to a canonical form, so that we can use string equality +// it: removes space, converts to lower case, ensures there is a trailing dot +func normalizeDNSName(dnsName string) string { + s := strings.TrimSpace(strings.ToLower(dnsName)) + if !strings.HasSuffix(s, ".") { + s += "." + } + return s +} + +// CompareBoolean is an implementation of PropertyComparator for comparing boolean-line values +// For example external-dns.alpha.kubernetes.io/cloudflare-proxied: "true" +// If value doesn't parse as boolean, the defaultValue is used +func CompareBoolean(defaultValue bool, name, current, previous string) bool { + var err error + + v1, v2 := defaultValue, defaultValue + + if previous != "" { + v1, err = strconv.ParseBool(previous) + if err != nil { + v1 = defaultValue + } + } + + if current != "" { + v2, err = strconv.ParseBool(current) + if err != nil { + v2 = defaultValue + } + } + + return v1 == v2 +} + +func IsManagedRecord(record string, managedRecords, excludeRecords []string) bool { + for _, r := range excludeRecords { + if record == r { + return false + } + } + for _, r := range managedRecords { + if record == r { + return true + } + } + return false +} diff --git a/internal/external-dns/plan/plan_test.go b/internal/external-dns/plan/plan_test.go new file mode 100644 index 00000000..0ec5fa64 --- /dev/null +++ b/internal/external-dns/plan/plan_test.go @@ -0,0 +1,1088 @@ +/* +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 plan + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "sigs.k8s.io/external-dns/endpoint" + + "github.com/kuadrant/dns-operator/internal/external-dns/testutils" +) + +type PlanTestSuite struct { + suite.Suite + fooV1Cname *endpoint.Endpoint + fooV2Cname *endpoint.Endpoint + fooV2CnameUppercase *endpoint.Endpoint + fooV2TXT *endpoint.Endpoint + fooV2CnameNoLabel *endpoint.Endpoint + fooV3CnameSameResource *endpoint.Endpoint + fooA5 *endpoint.Endpoint + fooAAAA *endpoint.Endpoint + dsA *endpoint.Endpoint + dsAAAA *endpoint.Endpoint + bar127A *endpoint.Endpoint + bar127AWithTTL *endpoint.Endpoint + bar127AWithProviderSpecificTrue *endpoint.Endpoint + bar127AWithProviderSpecificFalse *endpoint.Endpoint + bar127AWithProviderSpecificUnset *endpoint.Endpoint + bar192A *endpoint.Endpoint + multiple1 *endpoint.Endpoint + multiple2 *endpoint.Endpoint + multiple3 *endpoint.Endpoint + domainFilterFiltered1 *endpoint.Endpoint + domainFilterFiltered2 *endpoint.Endpoint + domainFilterFiltered3 *endpoint.Endpoint + domainFilterExcluded *endpoint.Endpoint +} + +func (suite *PlanTestSuite) SetupTest() { + suite.fooV1Cname = &endpoint.Endpoint{ + DNSName: "foo", + Targets: endpoint.Targets{"v1"}, + RecordType: "CNAME", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/foo-v1", + endpoint.OwnerLabelKey: "pwner", + }, + } + // same resource as fooV1Cname, but target is different. It will never be picked because its target lexicographically bigger than "v1" + suite.fooV3CnameSameResource = &endpoint.Endpoint{ // TODO: remove this once endpoint can support multiple targets + DNSName: "foo", + Targets: endpoint.Targets{"v3"}, + RecordType: "CNAME", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/foo-v1", + endpoint.OwnerLabelKey: "pwner", + }, + } + suite.fooV2Cname = &endpoint.Endpoint{ + DNSName: "foo", + Targets: endpoint.Targets{"v2"}, + RecordType: "CNAME", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/foo-v2", + }, + } + suite.fooV2CnameUppercase = &endpoint.Endpoint{ + DNSName: "foo", + Targets: endpoint.Targets{"V2"}, + RecordType: "CNAME", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/foo-v2", + }, + } + suite.fooV2TXT = &endpoint.Endpoint{ + DNSName: "foo", + RecordType: "TXT", + } + suite.fooV2CnameNoLabel = &endpoint.Endpoint{ + DNSName: "foo", + Targets: endpoint.Targets{"v2"}, + RecordType: "CNAME", + } + suite.fooA5 = &endpoint.Endpoint{ + DNSName: "foo", + Targets: endpoint.Targets{"5.5.5.5"}, + RecordType: "A", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/foo-5", + }, + } + suite.fooAAAA = &endpoint.Endpoint{ + DNSName: "foo", + Targets: endpoint.Targets{"2001:DB8::1"}, + RecordType: "AAAA", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/foo-AAAA", + }, + } + suite.dsA = &endpoint.Endpoint{ + DNSName: "ds", + Targets: endpoint.Targets{"1.1.1.1"}, + RecordType: "A", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/ds", + }, + } + suite.dsAAAA = &endpoint.Endpoint{ + DNSName: "ds", + Targets: endpoint.Targets{"2001:DB8::1"}, + RecordType: "AAAA", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/ds-AAAAA", + }, + } + suite.bar127A = &endpoint.Endpoint{ + DNSName: "bar", + Targets: endpoint.Targets{"127.0.0.1"}, + RecordType: "A", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/bar-127", + }, + } + suite.bar127AWithTTL = &endpoint.Endpoint{ + DNSName: "bar", + Targets: endpoint.Targets{"127.0.0.1"}, + RecordType: "A", + RecordTTL: 300, + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/bar-127", + }, + } + suite.bar127AWithProviderSpecificTrue = &endpoint.Endpoint{ + DNSName: "bar", + Targets: endpoint.Targets{"127.0.0.1"}, + RecordType: "A", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/bar-127", + }, + ProviderSpecific: endpoint.ProviderSpecific{ + endpoint.ProviderSpecificProperty{ + Name: "alias", + Value: "false", + }, + endpoint.ProviderSpecificProperty{ + Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", + Value: "true", + }, + }, + } + suite.bar127AWithProviderSpecificFalse = &endpoint.Endpoint{ + DNSName: "bar", + Targets: endpoint.Targets{"127.0.0.1"}, + RecordType: "A", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/bar-127", + }, + ProviderSpecific: endpoint.ProviderSpecific{ + endpoint.ProviderSpecificProperty{ + Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", + Value: "false", + }, + endpoint.ProviderSpecificProperty{ + Name: "alias", + Value: "false", + }, + }, + } + suite.bar127AWithProviderSpecificUnset = &endpoint.Endpoint{ + DNSName: "bar", + Targets: endpoint.Targets{"127.0.0.1"}, + RecordType: "A", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/bar-127", + }, + ProviderSpecific: endpoint.ProviderSpecific{ + endpoint.ProviderSpecificProperty{ + Name: "alias", + Value: "false", + }, + }, + } + suite.bar192A = &endpoint.Endpoint{ + DNSName: "bar", + Targets: endpoint.Targets{"192.168.0.1"}, + RecordType: "A", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/bar-192", + }, + } + suite.multiple1 = &endpoint.Endpoint{ + DNSName: "multiple", + Targets: endpoint.Targets{"192.168.0.1"}, + RecordType: "A", + SetIdentifier: "test-set-1", + } + suite.multiple2 = &endpoint.Endpoint{ + DNSName: "multiple", + Targets: endpoint.Targets{"192.168.0.2"}, + RecordType: "A", + SetIdentifier: "test-set-1", + } + suite.multiple3 = &endpoint.Endpoint{ + DNSName: "multiple", + Targets: endpoint.Targets{"192.168.0.2"}, + RecordType: "A", + SetIdentifier: "test-set-2", + } + suite.domainFilterFiltered1 = &endpoint.Endpoint{ + DNSName: "foo.domain.tld", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: "A", + } + suite.domainFilterFiltered2 = &endpoint.Endpoint{ + DNSName: "bar.domain.tld", + Targets: endpoint.Targets{"1.2.3.5"}, + RecordType: "A", + } + suite.domainFilterFiltered3 = &endpoint.Endpoint{ + DNSName: "baz.domain.tld", + Targets: endpoint.Targets{"1.2.3.6"}, + RecordType: "A", + } + suite.domainFilterExcluded = &endpoint.Endpoint{ + DNSName: "foo.ex.domain.tld", + Targets: endpoint.Targets{"1.1.1.1"}, + RecordType: "A", + } +} + +func (suite *PlanTestSuite) TestSyncFirstRound() { + current := []*endpoint.Endpoint{} + desired := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooV2Cname, suite.bar127A} + expectedCreate := []*endpoint.Endpoint{suite.fooV1Cname, suite.bar127A} // v1 is chosen because of resolver taking "min" + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestSyncSecondRound() { + current := []*endpoint.Endpoint{suite.fooV1Cname} + desired := []*endpoint.Endpoint{suite.fooV2Cname, suite.fooV1Cname, suite.bar127A} + expectedCreate := []*endpoint.Endpoint{suite.bar127A} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestSyncSecondRoundMigration() { + current := []*endpoint.Endpoint{suite.fooV2CnameNoLabel} + desired := []*endpoint.Endpoint{suite.fooV2Cname, suite.fooV1Cname, suite.bar127A} + expectedCreate := []*endpoint.Endpoint{suite.bar127A} + expectedUpdateOld := []*endpoint.Endpoint{suite.fooV2CnameNoLabel} + expectedUpdateNew := []*endpoint.Endpoint{suite.fooV1Cname} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestSyncSecondRoundWithTTLChange() { + current := []*endpoint.Endpoint{suite.bar127A} + desired := []*endpoint.Endpoint{suite.bar127AWithTTL} + expectedCreate := []*endpoint.Endpoint{} + expectedUpdateOld := []*endpoint.Endpoint{suite.bar127A} + expectedUpdateNew := []*endpoint.Endpoint{suite.bar127AWithTTL} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificChange() { + current := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue} + desired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse} + expectedCreate := []*endpoint.Endpoint{} + expectedUpdateOld := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue} + expectedUpdateNew := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificRemoval() { + current := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse} + desired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificUnset} + expectedCreate := []*endpoint.Endpoint{} + expectedUpdateOld := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse} + expectedUpdateNew := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificUnset} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificAddition() { + current := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificUnset} + desired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue} + expectedCreate := []*endpoint.Endpoint{} + expectedUpdateOld := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificUnset} + expectedUpdateNew := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestSyncSecondRoundWithOwnerInherited() { + current := []*endpoint.Endpoint{suite.fooV1Cname} + desired := []*endpoint.Endpoint{suite.fooV2Cname} + + expectedCreate := []*endpoint.Endpoint{} + expectedUpdateOld := []*endpoint.Endpoint{suite.fooV1Cname} + expectedUpdateNew := []*endpoint.Endpoint{{ + DNSName: suite.fooV2Cname.DNSName, + Targets: suite.fooV2Cname.Targets, + RecordType: suite.fooV2Cname.RecordType, + RecordTTL: suite.fooV2Cname.RecordTTL, + Labels: map[string]string{ + endpoint.ResourceLabelKey: suite.fooV2Cname.Labels[endpoint.ResourceLabelKey], + endpoint.OwnerLabelKey: suite.fooV1Cname.Labels[endpoint.OwnerLabelKey], + }, + }} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestIdempotency() { + current := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooV2Cname} + desired := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooV2Cname} + expectedCreate := []*endpoint.Endpoint{} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestRecordTypeChange() { + current := []*endpoint.Endpoint{suite.fooV1Cname} + desired := []*endpoint.Endpoint{suite.fooA5} + expectedCreate := []*endpoint.Endpoint{suite.fooA5} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{suite.fooV1Cname} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + OwnerID: suite.fooV1Cname.Labels[endpoint.OwnerLabelKey], + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestExistingCNameWithDualStackDesired() { + current := []*endpoint.Endpoint{suite.fooV1Cname} + desired := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA} + expectedCreate := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{suite.fooV1Cname} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + OwnerID: suite.fooV1Cname.Labels[endpoint.OwnerLabelKey], + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestExistingDualStackWithCNameDesired() { + suite.fooA5.Labels[endpoint.OwnerLabelKey] = "nerf" + suite.fooAAAA.Labels[endpoint.OwnerLabelKey] = "nerf" + current := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA} + desired := []*endpoint.Endpoint{suite.fooV2Cname} + expectedCreate := []*endpoint.Endpoint{suite.fooV2Cname} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + OwnerID: suite.fooA5.Labels[endpoint.OwnerLabelKey], + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +// TestExistingOwnerNotMatchingDualStackDesired validates that if there is an existing +// record for a domain but there is no ownership claim over it and there are desired +// records no changes are planed. Only domains that have explicit ownership claims should +// be updated. +func (suite *PlanTestSuite) TestExistingOwnerNotMatchingDualStackDesired() { + suite.fooA5.Labels = nil + current := []*endpoint.Endpoint{suite.fooA5} + desired := []*endpoint.Endpoint{suite.fooV2Cname} + expectedCreate := []*endpoint.Endpoint{} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + OwnerID: "pwner", + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +// TestConflictingCurrentNonConflictingDesired is a bit of a corner case as it would indicate +// that the provider is not following valid DNS rules or there may be some +// caching issues. In this case since the desired records are not conflicting +// the updates will end up with the conflict resolved. +func (suite *PlanTestSuite) TestConflictingCurrentNonConflictingDesired() { + suite.fooA5.Labels[endpoint.OwnerLabelKey] = suite.fooV1Cname.Labels[endpoint.OwnerLabelKey] + current := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5} + desired := []*endpoint.Endpoint{suite.fooA5} + expectedCreate := []*endpoint.Endpoint{} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{suite.fooV1Cname} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + OwnerID: suite.fooV1Cname.Labels[endpoint.OwnerLabelKey], + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +// TestConflictingCurrentNoDesired is a bit of a corner case as it would indicate +// that the provider is not following valid DNS rules or there may be some +// caching issues. In this case there are no desired enpoint candidates so plan +// on deleting the records. +func (suite *PlanTestSuite) TestConflictingCurrentNoDesired() { + suite.fooA5.Labels[endpoint.OwnerLabelKey] = suite.fooV1Cname.Labels[endpoint.OwnerLabelKey] + current := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5} + desired := []*endpoint.Endpoint{} + expectedCreate := []*endpoint.Endpoint{} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + OwnerID: suite.fooV1Cname.Labels[endpoint.OwnerLabelKey], + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +// TestCurrentWithConflictingDesired simulates where the desired records result in conflicting records types. +// This could be the result of multiple sources generating conflicting records types. In this case the conflict +// resolver should prefer the A and AAAA record candidate and delete the other records. +func (suite *PlanTestSuite) TestCurrentWithConflictingDesired() { + suite.fooV1Cname.Labels[endpoint.OwnerLabelKey] = "nerf" + current := []*endpoint.Endpoint{suite.fooV1Cname} + desired := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5, suite.fooAAAA} + expectedCreate := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{suite.fooV1Cname} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + OwnerID: suite.fooV1Cname.Labels[endpoint.OwnerLabelKey], + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +// TestNoCurrentWithConflictingDesired simulates where the desired records result in conflicting records types. +// This could be the result of multiple sources generating conflicting records types. In this case there the +// conflict resolver should prefer the A and AAAA record and drop the other candidate record types. +func (suite *PlanTestSuite) TestNoCurrentWithConflictingDesired() { + current := []*endpoint.Endpoint{} + desired := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5, suite.fooAAAA} + expectedCreate := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestIgnoreTXT() { + current := []*endpoint.Endpoint{suite.fooV2TXT} + desired := []*endpoint.Endpoint{suite.fooV2Cname} + expectedCreate := []*endpoint.Endpoint{suite.fooV2Cname} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestExcludeTXT() { + current := []*endpoint.Endpoint{suite.fooV2TXT} + desired := []*endpoint.Endpoint{suite.fooV2Cname} + expectedCreate := []*endpoint.Endpoint{suite.fooV2Cname} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME, endpoint.RecordTypeTXT}, + ExcludeRecords: []string{endpoint.RecordTypeTXT}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestIgnoreTargetCase() { + current := []*endpoint.Endpoint{suite.fooV2Cname} + desired := []*endpoint.Endpoint{suite.fooV2CnameUppercase} + expectedCreate := []*endpoint.Endpoint{} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestRemoveEndpoint() { + current := []*endpoint.Endpoint{suite.fooV1Cname, suite.bar192A} + desired := []*endpoint.Endpoint{suite.fooV1Cname} + expectedCreate := []*endpoint.Endpoint{} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{suite.bar192A} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestRemoveEndpointWithUpsert() { + current := []*endpoint.Endpoint{suite.fooV1Cname, suite.bar192A} + desired := []*endpoint.Endpoint{suite.fooV1Cname} + expectedCreate := []*endpoint.Endpoint{} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&UpsertOnlyPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestMultipleRecordsSameNameDifferentSetIdentifier() { + current := []*endpoint.Endpoint{suite.multiple1} + desired := []*endpoint.Endpoint{suite.multiple2, suite.multiple3} + expectedCreate := []*endpoint.Endpoint{suite.multiple3} + expectedUpdateOld := []*endpoint.Endpoint{suite.multiple1} + expectedUpdateNew := []*endpoint.Endpoint{suite.multiple2} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestSetIdentifierUpdateCreatesAndDeletes() { + current := []*endpoint.Endpoint{suite.multiple2} + desired := []*endpoint.Endpoint{suite.multiple3} + expectedCreate := []*endpoint.Endpoint{suite.multiple3} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{suite.multiple2} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestDomainFiltersInitial() { + current := []*endpoint.Endpoint{suite.domainFilterExcluded} + desired := []*endpoint.Endpoint{suite.domainFilterExcluded, suite.domainFilterFiltered1, suite.domainFilterFiltered2, suite.domainFilterFiltered3} + expectedCreate := []*endpoint.Endpoint{suite.domainFilterFiltered1, suite.domainFilterFiltered2, suite.domainFilterFiltered3} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{} + + domainFilter := endpoint.NewDomainFilterWithExclusions([]string{"domain.tld"}, []string{"ex.domain.tld"}) + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + DomainFilter: endpoint.MatchAllDomainFilters{&domainFilter}, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestDomainFiltersUpdate() { + current := []*endpoint.Endpoint{suite.domainFilterExcluded, suite.domainFilterFiltered1, suite.domainFilterFiltered2} + desired := []*endpoint.Endpoint{suite.domainFilterExcluded, suite.domainFilterFiltered1, suite.domainFilterFiltered2, suite.domainFilterFiltered3} + expectedCreate := []*endpoint.Endpoint{suite.domainFilterFiltered3} + expectedUpdateOld := []*endpoint.Endpoint{} + expectedUpdateNew := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{} + + domainFilter := endpoint.NewDomainFilterWithExclusions([]string{"domain.tld"}, []string{"ex.domain.tld"}) + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + DomainFilter: endpoint.MatchAllDomainFilters{&domainFilter}, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + +func (suite *PlanTestSuite) TestAAAARecords() { + current := []*endpoint.Endpoint{} + desired := []*endpoint.Endpoint{suite.fooAAAA} + expectedCreate := []*endpoint.Endpoint{suite.fooAAAA} + expectNoChanges := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.Delete, expectNoChanges) + validateEntries(suite.T(), changes.UpdateOld, expectNoChanges) + validateEntries(suite.T(), changes.UpdateNew, expectNoChanges) +} + +func (suite *PlanTestSuite) TestDualStackRecords() { + current := []*endpoint.Endpoint{} + desired := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA} + expectedCreate := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA} + expectNoChanges := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.Delete, expectNoChanges) + validateEntries(suite.T(), changes.UpdateOld, expectNoChanges) + validateEntries(suite.T(), changes.UpdateNew, expectNoChanges) +} + +func (suite *PlanTestSuite) TestDualStackRecordsDelete() { + current := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA} + desired := []*endpoint.Endpoint{} + expectedDelete := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA} + expectNoChanges := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Delete, expectedDelete) + validateEntries(suite.T(), changes.Create, expectNoChanges) + validateEntries(suite.T(), changes.UpdateOld, expectNoChanges) + validateEntries(suite.T(), changes.UpdateNew, expectNoChanges) +} + +func (suite *PlanTestSuite) TestDualStackToSingleStack() { + current := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA} + desired := []*endpoint.Endpoint{suite.dsA} + expectedDelete := []*endpoint.Endpoint{suite.dsAAAA} + expectNoChanges := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Delete, expectedDelete) + validateEntries(suite.T(), changes.Create, expectNoChanges) + validateEntries(suite.T(), changes.UpdateOld, expectNoChanges) + validateEntries(suite.T(), changes.UpdateNew, expectNoChanges) +} + +func TestPlan(t *testing.T) { + suite.Run(t, new(PlanTestSuite)) +} + +// validateEntries validates that the list of entries matches expected. +func validateEntries(t *testing.T, entries, expected []*endpoint.Endpoint) { + if !testutils.SameEndpoints(entries, expected) { + t.Fatalf("expected %q to match %q", entries, expected) + } +} + +func TestNormalizeDNSName(t *testing.T) { + records := []struct { + dnsName string + expect string + }{ + { + "3AAAA.FOO.BAR.COM ", + "3aaaa.foo.bar.com.", + }, + { + " example.foo.com.", + "example.foo.com.", + }, + { + "example123.foo.com ", + "example123.foo.com.", + }, + { + "foo", + "foo.", + }, + { + "123foo.bar", + "123foo.bar.", + }, + { + "foo.com", + "foo.com.", + }, + { + "foo.com.", + "foo.com.", + }, + { + "foo123.COM", + "foo123.com.", + }, + { + "my-exaMple3.FOO.BAR.COM", + "my-example3.foo.bar.com.", + }, + { + " my-example1214.FOO-1235.BAR-foo.COM ", + "my-example1214.foo-1235.bar-foo.com.", + }, + { + "my-example-my-example-1214.FOO-1235.BAR-foo.COM", + "my-example-my-example-1214.foo-1235.bar-foo.com.", + }, + } + for _, r := range records { + gotName := normalizeDNSName(r.dnsName) + assert.Equal(t, r.expect, gotName) + } +} + +func TestShouldUpdateProviderSpecific(tt *testing.T) { + for _, test := range []struct { + name string + current *endpoint.Endpoint + desired *endpoint.Endpoint + shouldUpdate bool + }{ + { + name: "skip AWS target health", + current: &endpoint.Endpoint{ + DNSName: "foo.com", + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + {Name: "aws/evaluate-target-health", Value: "true"}, + }, + }, + desired: &endpoint.Endpoint{ + DNSName: "bar.com", + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + {Name: "aws/evaluate-target-health", Value: "true"}, + }, + }, + shouldUpdate: false, + }, + { + name: "custom property unchanged", + current: &endpoint.Endpoint{ + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + {Name: "custom/property", Value: "true"}, + }, + }, + desired: &endpoint.Endpoint{ + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + {Name: "custom/property", Value: "true"}, + }, + }, + shouldUpdate: false, + }, + { + name: "custom property value changed", + current: &endpoint.Endpoint{ + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + {Name: "custom/property", Value: "true"}, + }, + }, + desired: &endpoint.Endpoint{ + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + {Name: "custom/property", Value: "false"}, + }, + }, + shouldUpdate: true, + }, + { + name: "custom property key changed", + current: &endpoint.Endpoint{ + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + {Name: "custom/property", Value: "true"}, + }, + }, + desired: &endpoint.Endpoint{ + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + {Name: "new/property", Value: "true"}, + }, + }, + shouldUpdate: true, + }, + } { + tt.Run(test.name, func(t *testing.T) { + plan := &Plan{ + Current: []*endpoint.Endpoint{test.current}, + Desired: []*endpoint.Endpoint{test.desired}, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, + } + b := plan.shouldUpdateProviderSpecific(test.desired, test.current) + assert.Equal(t, test.shouldUpdate, b) + }) + } +} diff --git a/internal/external-dns/plan/policy.go b/internal/external-dns/plan/policy.go new file mode 100644 index 00000000..af277dd7 --- /dev/null +++ b/internal/external-dns/plan/policy.go @@ -0,0 +1,63 @@ +/* +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 plan + +import ( + "sigs.k8s.io/external-dns/plan" +) + +// Policy allows to apply different rules to a set of changes. +type Policy interface { + Apply(changes *plan.Changes) *plan.Changes +} + +// Policies is a registry of available policies. +var Policies = map[string]Policy{ + "sync": &SyncPolicy{}, + "upsert-only": &UpsertOnlyPolicy{}, + "create-only": &CreateOnlyPolicy{}, +} + +// SyncPolicy allows for full synchronization of DNS records. +type SyncPolicy struct{} + +// Apply applies the sync policy which returns the set of changes as is. +func (p *SyncPolicy) Apply(changes *plan.Changes) *plan.Changes { + return changes +} + +// UpsertOnlyPolicy allows everything but deleting DNS records. +type UpsertOnlyPolicy struct{} + +// Apply applies the upsert-only policy which strips out any deletions. +func (p *UpsertOnlyPolicy) Apply(changes *plan.Changes) *plan.Changes { + return &plan.Changes{ + Create: changes.Create, + UpdateOld: changes.UpdateOld, + UpdateNew: changes.UpdateNew, + } +} + +// CreateOnlyPolicy allows only creating DNS records. +type CreateOnlyPolicy struct{} + +// Apply applies the create-only policy which strips out updates and deletions. +func (p *CreateOnlyPolicy) Apply(changes *plan.Changes) *plan.Changes { + return &plan.Changes{ + Create: changes.Create, + } +} diff --git a/internal/external-dns/plan/policy_test.go b/internal/external-dns/plan/policy_test.go new file mode 100644 index 00000000..94b71b1f --- /dev/null +++ b/internal/external-dns/plan/policy_test.go @@ -0,0 +1,89 @@ +/* +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 plan + +import ( + "reflect" + "testing" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" +) + +// TestApply tests that applying a policy results in the correct set of changes. +func TestApply(t *testing.T) { + // empty list of records + empty := []*endpoint.Endpoint{} + // a simple entry + fooV1 := []*endpoint.Endpoint{{DNSName: "foo", Targets: endpoint.Targets{"v1"}}} + // the same entry but with different target + fooV2 := []*endpoint.Endpoint{{DNSName: "foo", Targets: endpoint.Targets{"v2"}}} + // another two simple entries + bar := []*endpoint.Endpoint{{DNSName: "bar", Targets: endpoint.Targets{"v1"}}} + baz := []*endpoint.Endpoint{{DNSName: "baz", Targets: endpoint.Targets{"v1"}}} + + for _, tc := range []struct { + policy Policy + changes *plan.Changes + expected *plan.Changes + }{ + { + // SyncPolicy doesn't modify the set of changes. + &SyncPolicy{}, + &plan.Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: bar}, + &plan.Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: bar}, + }, + { + // UpsertOnlyPolicy clears the list of deletions. + &UpsertOnlyPolicy{}, + &plan.Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: bar}, + &plan.Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: empty}, + }, + { + // CreateOnlyPolicy clears the list of updates and deletions. + &CreateOnlyPolicy{}, + &plan.Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: bar}, + &plan.Changes{Create: baz, UpdateOld: empty, UpdateNew: empty, Delete: empty}, + }, + } { + // apply policy + changes := tc.policy.Apply(tc.changes) + + // validate changes after applying policy + validateEntries(t, changes.Create, tc.expected.Create) + validateEntries(t, changes.UpdateOld, tc.expected.UpdateOld) + validateEntries(t, changes.UpdateNew, tc.expected.UpdateNew) + validateEntries(t, changes.Delete, tc.expected.Delete) + } +} + +// TestPolicies tests that policies are correctly registered. +func TestPolicies(t *testing.T) { + validatePolicy(t, Policies["sync"], &SyncPolicy{}) + validatePolicy(t, Policies["upsert-only"], &UpsertOnlyPolicy{}) + validatePolicy(t, Policies["create-only"], &CreateOnlyPolicy{}) +} + +// validatePolicy validates that a given policy is of the given type. +func validatePolicy(t *testing.T, policy, expected Policy) { + policyType := reflect.TypeOf(policy).String() + expectedType := reflect.TypeOf(expected).String() + + if policyType != expectedType { + t.Errorf("expected %q to match %q", policyType, expectedType) + } +} diff --git a/internal/external-dns/registry/txt.go b/internal/external-dns/registry/txt.go new file mode 100644 index 00000000..8e7b6f09 --- /dev/null +++ b/internal/external-dns/registry/txt.go @@ -0,0 +1,489 @@ +/* +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 registry + +import ( + "context" + "errors" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" +) + +const ( + recordTemplate = "%{record_type}" + providerSpecificForceUpdate = "txt/force-update" +) + +// TXTRegistry implements registry interface with ownership implemented via associated TXT records +type TXTRegistry struct { + provider provider.Provider + ownerID string // refers to the owner id of the current instance + mapper nameMapper + + // cache the records in memory and update on an interval instead. + recordsCache []*endpoint.Endpoint + recordsCacheRefreshTime time.Time + cacheInterval time.Duration + + // optional string to use to replace the asterisk in wildcard entries - without using this, + // registry TXT records corresponding to wildcard records will be invalid (and rejected by most providers), due to + // having a '*' appear (not as the first character) - see https://tools.ietf.org/html/rfc1034#section-4.3.3 + wildcardReplacement string + + managedRecordTypes []string + excludeRecordTypes []string + + // encrypt text records + txtEncryptEnabled bool + txtEncryptAESKey []byte +} + +// NewTXTRegistry returns new TXTRegistry object +func NewTXTRegistry(provider provider.Provider, txtPrefix, txtSuffix, ownerID string, cacheInterval time.Duration, txtWildcardReplacement string, managedRecordTypes, excludeRecordTypes []string, txtEncryptEnabled bool, txtEncryptAESKey []byte) (*TXTRegistry, error) { + if ownerID == "" { + return nil, errors.New("owner id cannot be empty") + } + if len(txtEncryptAESKey) == 0 { + txtEncryptAESKey = nil + } else if len(txtEncryptAESKey) != 32 { + return nil, errors.New("the AES Encryption key must have a length of 32 bytes") + } + if txtEncryptEnabled && txtEncryptAESKey == nil { + return nil, errors.New("the AES Encryption key must be set when TXT record encryption is enabled") + } + + if len(txtPrefix) > 0 && len(txtSuffix) > 0 { + return nil, errors.New("txt-prefix and txt-suffix are mutual exclusive") + } + + mapper := newaffixNameMapper(txtPrefix, txtSuffix, txtWildcardReplacement) + + return &TXTRegistry{ + provider: provider, + ownerID: ownerID, + mapper: mapper, + cacheInterval: cacheInterval, + wildcardReplacement: txtWildcardReplacement, + managedRecordTypes: managedRecordTypes, + excludeRecordTypes: excludeRecordTypes, + txtEncryptEnabled: txtEncryptEnabled, + txtEncryptAESKey: txtEncryptAESKey, + }, nil +} + +func getSupportedTypes() []string { + return []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME, endpoint.RecordTypeNS} +} + +func (im *TXTRegistry) GetDomainFilter() endpoint.DomainFilter { + return im.provider.GetDomainFilter() +} + +func (im *TXTRegistry) OwnerID() string { + return im.ownerID +} + +// Records returns the current records from the registry excluding TXT Records +// If TXT records was created previously to indicate ownership its corresponding value +// will be added to the endpoints Labels map +func (im *TXTRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { + // If we have the zones cached AND we have refreshed the cache since the + // last given interval, then just use the cached results. + if im.recordsCache != nil && time.Since(im.recordsCacheRefreshTime) < im.cacheInterval { + log.Debug("Using cached records.") + return im.recordsCache, nil + } + + records, err := im.provider.Records(ctx) + if err != nil { + return nil, err + } + + endpoints := []*endpoint.Endpoint{} + + labelMap := map[endpoint.EndpointKey]endpoint.Labels{} + txtRecordsMap := map[string]struct{}{} + + for _, record := range records { + if record.RecordType != endpoint.RecordTypeTXT { + endpoints = append(endpoints, record) + continue + } + // We simply assume that TXT records for the registry will always have only one target. + labels, err := endpoint.NewLabelsFromString(record.Targets[0], im.txtEncryptAESKey) + if err == endpoint.ErrInvalidHeritage { + // if no heritage is found or it is invalid + // case when value of txt record cannot be identified + // record will not be removed as it will have empty owner + endpoints = append(endpoints, record) + continue + } + if err != nil { + return nil, err + } + + endpointName, recordType := im.mapper.toEndpointName(record.DNSName) + key := endpoint.EndpointKey{ + DNSName: endpointName, + RecordType: recordType, + SetIdentifier: record.SetIdentifier, + } + labelMap[key] = labels + txtRecordsMap[record.DNSName] = struct{}{} + } + + for _, ep := range endpoints { + if ep.Labels == nil { + ep.Labels = endpoint.NewLabels() + } + dnsNameSplit := strings.Split(ep.DNSName, ".") + // If specified, replace a leading asterisk in the generated txt record name with some other string + if im.wildcardReplacement != "" && dnsNameSplit[0] == "*" { + dnsNameSplit[0] = im.wildcardReplacement + } + dnsName := strings.Join(dnsNameSplit, ".") + key := endpoint.EndpointKey{ + DNSName: dnsName, + RecordType: ep.RecordType, + SetIdentifier: ep.SetIdentifier, + } + + // AWS Alias records have "new" format encoded as type "cname" + if isAlias, found := ep.GetProviderSpecificProperty("alias"); found && isAlias == "true" && ep.RecordType == endpoint.RecordTypeA { + key.RecordType = endpoint.RecordTypeCNAME + } + + // Handle both new and old registry format with the preference for the new one + labels, labelsExist := labelMap[key] + if !labelsExist && ep.RecordType != endpoint.RecordTypeAAAA { + key.RecordType = "" + labels, labelsExist = labelMap[key] + } + if labelsExist { + for k, v := range labels { + ep.Labels[k] = v + } + } + + // Handle the migration of TXT records created before the new format (introduced in v0.12.0). + // The migration is done for the TXT records owned by this instance only. + if len(txtRecordsMap) > 0 && ep.Labels[endpoint.OwnerLabelKey] == im.ownerID { + if plan.IsManagedRecord(ep.RecordType, im.managedRecordTypes, im.excludeRecordTypes) { + // Get desired TXT records and detect the missing ones + desiredTXTs := im.generateTXTRecord(ep) + for _, desiredTXT := range desiredTXTs { + if _, exists := txtRecordsMap[desiredTXT.DNSName]; !exists { + ep.WithProviderSpecific(providerSpecificForceUpdate, "true") + } + } + } + } + } + + // Update the cache. + if im.cacheInterval > 0 { + im.recordsCache = endpoints + im.recordsCacheRefreshTime = time.Now() + } + + return endpoints, nil +} + +// generateTXTRecord generates both "old" and "new" TXT records. +// Once we decide to drop old format we need to drop toTXTName() and rename toNewTXTName +func (im *TXTRegistry) generateTXTRecord(r *endpoint.Endpoint) []*endpoint.Endpoint { + endpoints := make([]*endpoint.Endpoint, 0) + + if !im.txtEncryptEnabled && !im.mapper.recordTypeInAffix() && r.RecordType != endpoint.RecordTypeAAAA { + // old TXT record format + txt := endpoint.NewEndpoint(im.mapper.toTXTName(r.DNSName), endpoint.RecordTypeTXT, r.Labels.Serialize(true, im.txtEncryptEnabled, im.txtEncryptAESKey)) + if txt != nil { + txt.WithSetIdentifier(r.SetIdentifier) + txt.Labels[endpoint.OwnedRecordLabelKey] = r.DNSName + txt.ProviderSpecific = r.ProviderSpecific + endpoints = append(endpoints, txt) + } + } + // new TXT record format (containing record type) + recordType := r.RecordType + // AWS Alias records are encoded as type "cname" + if isAlias, found := r.GetProviderSpecificProperty("alias"); found && isAlias == "true" && recordType == endpoint.RecordTypeA { + recordType = endpoint.RecordTypeCNAME + } + txtNew := endpoint.NewEndpoint(im.mapper.toNewTXTName(r.DNSName, recordType), endpoint.RecordTypeTXT, r.Labels.Serialize(true, im.txtEncryptEnabled, im.txtEncryptAESKey)) + if txtNew != nil { + txtNew.WithSetIdentifier(r.SetIdentifier) + txtNew.Labels[endpoint.OwnedRecordLabelKey] = r.DNSName + txtNew.ProviderSpecific = r.ProviderSpecific + endpoints = append(endpoints, txtNew) + } + + return endpoints +} + +// ApplyChanges updates dns provider with the changes +// for each created/deleted record it will also take into account TXT records for creation/deletion +func (im *TXTRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + filteredChanges := &plan.Changes{ + Create: changes.Create, + UpdateNew: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateNew), + UpdateOld: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateOld), + Delete: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.Delete), + } + for _, r := range filteredChanges.Create { + if r.Labels == nil { + r.Labels = make(map[string]string) + } + r.Labels[endpoint.OwnerLabelKey] = im.ownerID + + filteredChanges.Create = append(filteredChanges.Create, im.generateTXTRecord(r)...) + + if im.cacheInterval > 0 { + im.addToCache(r) + } + } + + for _, r := range filteredChanges.Delete { + // when we delete TXT records for which value has changed (due to new label) this would still work because + // !!! TXT record value is uniquely generated from the Labels of the endpoint. Hence old TXT record can be uniquely reconstructed + // !!! After migration to the new TXT registry format we can drop records in old format here!!! + filteredChanges.Delete = append(filteredChanges.Delete, im.generateTXTRecord(r)...) + + if im.cacheInterval > 0 { + im.removeFromCache(r) + } + } + + // make sure TXT records are consistently updated as well + for _, r := range filteredChanges.UpdateOld { + // when we updateOld TXT records for which value has changed (due to new label) this would still work because + // !!! TXT record value is uniquely generated from the Labels of the endpoint. Hence old TXT record can be uniquely reconstructed + filteredChanges.UpdateOld = append(filteredChanges.UpdateOld, im.generateTXTRecord(r)...) + // remove old version of record from cache + if im.cacheInterval > 0 { + im.removeFromCache(r) + } + } + + // make sure TXT records are consistently updated as well + for _, r := range filteredChanges.UpdateNew { + filteredChanges.UpdateNew = append(filteredChanges.UpdateNew, im.generateTXTRecord(r)...) + // add new version of record to cache + if im.cacheInterval > 0 { + im.addToCache(r) + } + } + + // when caching is enabled, disable the provider from using the cache + if im.cacheInterval > 0 { + ctx = context.WithValue(ctx, provider.RecordsContextKey, nil) + } + return im.provider.ApplyChanges(ctx, filteredChanges) +} + +// AdjustEndpoints modifies the endpoints as needed by the specific provider +func (im *TXTRegistry) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { + return im.provider.AdjustEndpoints(endpoints) +} + +/** + nameMapper is the interface for mapping between the endpoint for the source + and the endpoint for the TXT record. +*/ + +type nameMapper interface { + toEndpointName(string) (endpointName string, recordType string) + toTXTName(string) string + toNewTXTName(string, string) string + recordTypeInAffix() bool +} + +type affixNameMapper struct { + prefix string + suffix string + wildcardReplacement string +} + +var _ nameMapper = affixNameMapper{} + +func newaffixNameMapper(prefix, suffix, wildcardReplacement string) affixNameMapper { + return affixNameMapper{prefix: strings.ToLower(prefix), suffix: strings.ToLower(suffix), wildcardReplacement: strings.ToLower(wildcardReplacement)} +} + +// extractRecordTypeDefaultPosition extracts record type from the default position +// when not using '%{record_type}' in the prefix/suffix +func extractRecordTypeDefaultPosition(name string) (baseName, recordType string) { + nameS := strings.Split(name, "-") + for _, t := range getSupportedTypes() { + if nameS[0] == strings.ToLower(t) { + return strings.TrimPrefix(name, nameS[0]+"-"), t + } + } + return name, "" +} + +// dropAffixExtractType strips TXT record to find an endpoint name it manages +// it also returns the record type +func (pr affixNameMapper) dropAffixExtractType(name string) (baseName, recordType string) { + prefix := pr.prefix + suffix := pr.suffix + + if pr.recordTypeInAffix() { + for _, t := range getSupportedTypes() { + tLower := strings.ToLower(t) + iPrefix := strings.ReplaceAll(prefix, recordTemplate, tLower) + iSuffix := strings.ReplaceAll(suffix, recordTemplate, tLower) + + if pr.isPrefix() && strings.HasPrefix(name, iPrefix) { + return strings.TrimPrefix(name, iPrefix), t + } + + if pr.isSuffix() && strings.HasSuffix(name, iSuffix) { + return strings.TrimSuffix(name, iSuffix), t + } + } + + // handle old TXT records + prefix = pr.dropAffixTemplate(prefix) + suffix = pr.dropAffixTemplate(suffix) + } + + if pr.isPrefix() && strings.HasPrefix(name, prefix) { + return extractRecordTypeDefaultPosition(strings.TrimPrefix(name, prefix)) + } + + if pr.isSuffix() && strings.HasSuffix(name, suffix) { + return extractRecordTypeDefaultPosition(strings.TrimSuffix(name, suffix)) + } + + return "", "" +} + +func (pr affixNameMapper) dropAffixTemplate(name string) string { + return strings.ReplaceAll(name, recordTemplate, "") +} + +func (pr affixNameMapper) isPrefix() bool { + return len(pr.suffix) == 0 +} + +func (pr affixNameMapper) isSuffix() bool { + return len(pr.prefix) == 0 && len(pr.suffix) > 0 +} + +func (pr affixNameMapper) toEndpointName(txtDNSName string) (endpointName string, recordType string) { + lowerDNSName := strings.ToLower(txtDNSName) + + // drop prefix + if pr.isPrefix() { + return pr.dropAffixExtractType(lowerDNSName) + } + + // drop suffix + if pr.isSuffix() { + dc := strings.Count(pr.suffix, ".") + DNSName := strings.SplitN(lowerDNSName, ".", 2+dc) + domainWithSuffix := strings.Join(DNSName[:1+dc], ".") + + r, rType := pr.dropAffixExtractType(domainWithSuffix) + return r + "." + DNSName[1+dc], rType + } + return "", "" +} + +func (pr affixNameMapper) toTXTName(endpointDNSName string) string { + DNSName := strings.SplitN(endpointDNSName, ".", 2) + + prefix := pr.dropAffixTemplate(pr.prefix) + suffix := pr.dropAffixTemplate(pr.suffix) + // If specified, replace a leading asterisk in the generated txt record name with some other string + if pr.wildcardReplacement != "" && DNSName[0] == "*" { + DNSName[0] = pr.wildcardReplacement + } + + if len(DNSName) < 2 { + return prefix + DNSName[0] + suffix + } + return prefix + DNSName[0] + suffix + "." + DNSName[1] +} + +func (pr affixNameMapper) recordTypeInAffix() bool { + if strings.Contains(pr.prefix, recordTemplate) { + return true + } + if strings.Contains(pr.suffix, recordTemplate) { + return true + } + return false +} + +func (pr affixNameMapper) normalizeAffixTemplate(afix, recordType string) string { + if strings.Contains(afix, recordTemplate) { + return strings.ReplaceAll(afix, recordTemplate, recordType) + } + return afix +} + +func (pr affixNameMapper) toNewTXTName(endpointDNSName, recordType string) string { + DNSName := strings.SplitN(endpointDNSName, ".", 2) + recordType = strings.ToLower(recordType) + recordT := recordType + "-" + + prefix := pr.normalizeAffixTemplate(pr.prefix, recordType) + suffix := pr.normalizeAffixTemplate(pr.suffix, recordType) + + // If specified, replace a leading asterisk in the generated txt record name with some other string + if pr.wildcardReplacement != "" && DNSName[0] == "*" { + DNSName[0] = pr.wildcardReplacement + } + + if !pr.recordTypeInAffix() { + DNSName[0] = recordT + DNSName[0] + } + + if len(DNSName) < 2 { + return prefix + DNSName[0] + suffix + } + + return prefix + DNSName[0] + suffix + "." + DNSName[1] +} + +func (im *TXTRegistry) addToCache(ep *endpoint.Endpoint) { + if im.recordsCache != nil { + im.recordsCache = append(im.recordsCache, ep) + } +} + +func (im *TXTRegistry) removeFromCache(ep *endpoint.Endpoint) { + if im.recordsCache == nil || ep == nil { + return + } + + for i, e := range im.recordsCache { + if e.DNSName == ep.DNSName && e.RecordType == ep.RecordType && e.SetIdentifier == ep.SetIdentifier && e.Targets.Same(ep.Targets) { + // We found a match delete the endpoint from the cache. + im.recordsCache = append(im.recordsCache[:i], im.recordsCache[i+1:]...) + return + } + } +} diff --git a/internal/external-dns/registry/txt_test.go b/internal/external-dns/registry/txt_test.go new file mode 100644 index 00000000..25144037 --- /dev/null +++ b/internal/external-dns/registry/txt_test.go @@ -0,0 +1,1678 @@ +/* +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 registry + +import ( + "context" + "reflect" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" + "sigs.k8s.io/external-dns/provider/inmemory" + + "github.com/kuadrant/dns-operator/internal/external-dns/testutils" +) + +const ( + testZone = "test-zone.example.org" +) + +func TestTXTRegistry(t *testing.T) { + t.Run("TestNewTXTRegistry", testTXTRegistryNew) + t.Run("TestRecords", testTXTRegistryRecords) + t.Run("TestApplyChanges", testTXTRegistryApplyChanges) + t.Run("TestMissingRecords", testTXTRegistryMissingRecords) +} + +func testTXTRegistryNew(t *testing.T) { + p := inmemory.NewInMemoryProvider() + _, err := NewTXTRegistry(p, "txt", "", "", time.Hour, "", []string{}, []string{}, false, nil) + require.Error(t, err) + + _, err = NewTXTRegistry(p, "", "txt", "", time.Hour, "", []string{}, []string{}, false, nil) + require.Error(t, err) + + r, err := NewTXTRegistry(p, "txt", "", "owner", time.Hour, "", []string{}, []string{}, false, nil) + require.NoError(t, err) + assert.Equal(t, p, r.provider) + + r, err = NewTXTRegistry(p, "", "txt", "owner", time.Hour, "", []string{}, []string{}, false, nil) + require.NoError(t, err) + + _, err = NewTXTRegistry(p, "txt", "txt", "owner", time.Hour, "", []string{}, []string{}, false, nil) + require.Error(t, err) + + _, ok := r.mapper.(affixNameMapper) + require.True(t, ok) + assert.Equal(t, "owner", r.ownerID) + assert.Equal(t, p, r.provider) + + aesKey := []byte(";k&l)nUC/33:{?d{3)54+,AD?]SX%yh^") + _, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil) + require.NoError(t, err) + + _, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, aesKey) + require.NoError(t, err) + + _, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, nil) + require.Error(t, err) + + r, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, aesKey) + require.NoError(t, err) + + _, ok = r.mapper.(affixNameMapper) + assert.True(t, ok) +} + +func testTXTRegistryRecords(t *testing.T) { + t.Run("With prefix", testTXTRegistryRecordsPrefixed) + t.Run("With suffix", testTXTRegistryRecordsSuffixed) + t.Run("No prefix", testTXTRegistryRecordsNoPrefix) + t.Run("With templated prefix", testTXTRegistryRecordsPrefixedTemplated) + t.Run("With templated suffix", testTXTRegistryRecordsSuffixedTemplated) +} + +func testTXTRegistryRecordsPrefixed(t *testing.T) { + ctx := context.Background() + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwnerAndLabels("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, "", endpoint.Labels{"foo": "somefoo"}), + newEndpointWithOwnerAndLabels("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, "", endpoint.Labels{"bar": "somebar"}), + newEndpointWithOwner("txt.bar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("txt.bar.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), + newEndpointWithOwnerAndLabels("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "", endpoint.Labels{"tar": "sometar"}), + newEndpointWithOwner("TxT.tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-2\"", endpoint.RecordTypeTXT, ""), // case-insensitive TXT prefix + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-1"), + newEndpointWithOwner("multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-1"), + newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-2"), + newEndpointWithOwner("multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), + newEndpointWithOwner("*.wildcard.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("txt.wc.wildcard.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("dualstack.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), + newEndpointWithOwner("txt.dualstack.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("dualstack.test-zone.example.org", "2001:DB8::1", endpoint.RecordTypeAAAA, ""), + newEndpointWithOwner("txt.aaaa-dualstack.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-2\"", endpoint.RecordTypeTXT, ""), + }, + }) + expectedRecords := []*endpoint.Endpoint{ + { + DNSName: "foo.test-zone.example.org", + Targets: endpoint.Targets{"foo.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + "foo": "somefoo", + }, + }, + { + DNSName: "bar.test-zone.example.org", + Targets: endpoint.Targets{"my-domain.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + "bar": "somebar", + }, + }, + { + DNSName: "txt.bar.test-zone.example.org", + Targets: endpoint.Targets{"baz.test-zone.example.org"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "qux.test-zone.example.org", + Targets: endpoint.Targets{"random"}, + RecordType: endpoint.RecordTypeTXT, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "tar.test-zone.example.org", + Targets: endpoint.Targets{"tar.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner-2", + "tar": "sometar", + }, + }, + { + DNSName: "foobar.test-zone.example.org", + Targets: endpoint.Targets{"foobar.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "multiple.test-zone.example.org", + Targets: endpoint.Targets{"lb1.loadbalancer.com"}, + SetIdentifier: "test-set-1", + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "multiple.test-zone.example.org", + Targets: endpoint.Targets{"lb2.loadbalancer.com"}, + SetIdentifier: "test-set-2", + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "*.wildcard.test-zone.example.org", + Targets: endpoint.Targets{"foo.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + }, + { + DNSName: "dualstack.test-zone.example.org", + Targets: endpoint.Targets{"1.1.1.1"}, + RecordType: endpoint.RecordTypeA, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + }, + { + DNSName: "dualstack.test-zone.example.org", + Targets: endpoint.Targets{"2001:DB8::1"}, + RecordType: endpoint.RecordTypeAAAA, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner-2", + }, + }, + } + + r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil) + records, _ := r.Records(ctx) + + assert.True(t, testutils.SameEndpoints(records, expectedRecords)) + + // Ensure prefix is case-insensitive + r, _ = NewTXTRegistry(p, "TxT.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil) + records, _ = r.Records(ctx) + + assert.True(t, testutils.SameEndpoints(records, expectedRecords)) +} + +func testTXTRegistryRecordsSuffixed(t *testing.T) { + ctx := context.Background() + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwnerAndLabels("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, "", endpoint.Labels{"foo": "somefoo"}), + newEndpointWithOwnerAndLabels("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, "", endpoint.Labels{"bar": "somebar"}), + newEndpointWithOwner("bar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("bar-txt.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), + newEndpointWithOwnerAndLabels("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "", endpoint.Labels{"tar": "sometar"}), + newEndpointWithOwner("tar-TxT.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-2\"", endpoint.RecordTypeTXT, ""), // case-insensitive TXT prefix + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-1"), + newEndpointWithOwner("multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-1"), + newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-2"), + newEndpointWithOwner("multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), + newEndpointWithOwner("dualstack.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), + newEndpointWithOwner("dualstack-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("dualstack.test-zone.example.org", "2001:DB8::1", endpoint.RecordTypeAAAA, ""), + newEndpointWithOwner("aaaa-dualstack-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-2\"", endpoint.RecordTypeTXT, ""), + }, + }) + expectedRecords := []*endpoint.Endpoint{ + { + DNSName: "foo.test-zone.example.org", + Targets: endpoint.Targets{"foo.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + "foo": "somefoo", + }, + }, + { + DNSName: "bar.test-zone.example.org", + Targets: endpoint.Targets{"my-domain.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + "bar": "somebar", + }, + }, + { + DNSName: "bar-txt.test-zone.example.org", + Targets: endpoint.Targets{"baz.test-zone.example.org"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "qux.test-zone.example.org", + Targets: endpoint.Targets{"random"}, + RecordType: endpoint.RecordTypeTXT, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "tar.test-zone.example.org", + Targets: endpoint.Targets{"tar.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner-2", + "tar": "sometar", + }, + }, + { + DNSName: "foobar.test-zone.example.org", + Targets: endpoint.Targets{"foobar.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "multiple.test-zone.example.org", + Targets: endpoint.Targets{"lb1.loadbalancer.com"}, + SetIdentifier: "test-set-1", + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "multiple.test-zone.example.org", + Targets: endpoint.Targets{"lb2.loadbalancer.com"}, + SetIdentifier: "test-set-2", + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "dualstack.test-zone.example.org", + Targets: endpoint.Targets{"1.1.1.1"}, + RecordType: endpoint.RecordTypeA, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + }, + { + DNSName: "dualstack.test-zone.example.org", + Targets: endpoint.Targets{"2001:DB8::1"}, + RecordType: endpoint.RecordTypeAAAA, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner-2", + }, + }, + } + + r, _ := NewTXTRegistry(p, "", "-txt", "owner", time.Hour, "", []string{}, []string{}, false, nil) + records, _ := r.Records(ctx) + + assert.True(t, testutils.SameEndpoints(records, expectedRecords)) + + // Ensure prefix is case-insensitive + r, _ = NewTXTRegistry(p, "", "-TxT", "owner", time.Hour, "", []string{}, []string{}, false, nil) + records, _ = r.Records(ctx) + + assert.True(t, testutils.SameEndpointLabels(records, expectedRecords)) +} + +func testTXTRegistryRecordsNoPrefix(t *testing.T) { + p := inmemory.NewInMemoryProvider() + ctx := context.Background() + p.CreateZone(testZone) + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("alias.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "").WithProviderSpecific("alias", "true"), + newEndpointWithOwner("cname-alias.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("txt.bar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("txt.bar.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("txt.tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-2\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("dualstack.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), + newEndpointWithOwner("dualstack.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("dualstack.test-zone.example.org", "2001:DB8::1", endpoint.RecordTypeAAAA, ""), + newEndpointWithOwner("aaaa-dualstack.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-2\"", endpoint.RecordTypeTXT, ""), + }, + }) + expectedRecords := []*endpoint.Endpoint{ + { + DNSName: "foo.test-zone.example.org", + Targets: endpoint.Targets{"foo.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "bar.test-zone.example.org", + Targets: endpoint.Targets{"my-domain.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "alias.test-zone.example.org", + Targets: endpoint.Targets{"my-domain.com"}, + RecordType: endpoint.RecordTypeA, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + { + Name: "alias", + Value: "true", + }, + }, + }, + { + DNSName: "txt.bar.test-zone.example.org", + Targets: endpoint.Targets{"baz.test-zone.example.org"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + endpoint.ResourceLabelKey: "ingress/default/my-ingress", + }, + }, + { + DNSName: "qux.test-zone.example.org", + Targets: endpoint.Targets{"random"}, + RecordType: endpoint.RecordTypeTXT, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "tar.test-zone.example.org", + Targets: endpoint.Targets{"tar.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "foobar.test-zone.example.org", + Targets: endpoint.Targets{"foobar.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + }, + { + DNSName: "dualstack.test-zone.example.org", + Targets: endpoint.Targets{"1.1.1.1"}, + RecordType: endpoint.RecordTypeA, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + }, + { + DNSName: "dualstack.test-zone.example.org", + Targets: endpoint.Targets{"2001:DB8::1"}, + RecordType: endpoint.RecordTypeAAAA, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner-2", + }, + }, + } + + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil) + records, _ := r.Records(ctx) + + assert.True(t, testutils.SameEndpoints(records, expectedRecords)) +} + +func testTXTRegistryRecordsPrefixedTemplated(t *testing.T) { + ctx := context.Background() + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("foo.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), + newEndpointWithOwner("txt-a.foo.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + }, + }) + expectedRecords := []*endpoint.Endpoint{ + { + DNSName: "foo.test-zone.example.org", + Targets: endpoint.Targets{"1.1.1.1"}, + RecordType: endpoint.RecordTypeA, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + }, + } + + r, _ := NewTXTRegistry(p, "txt-%{record_type}.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil) + records, _ := r.Records(ctx) + + assert.True(t, testutils.SameEndpoints(records, expectedRecords)) + + r, _ = NewTXTRegistry(p, "TxT-%{record_type}.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil) + records, _ = r.Records(ctx) + + assert.True(t, testutils.SameEndpoints(records, expectedRecords)) +} + +func testTXTRegistryRecordsSuffixedTemplated(t *testing.T) { + ctx := context.Background() + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("bar.test-zone.example.org", "8.8.8.8", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("bartxtcname.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + }, + }) + expectedRecords := []*endpoint.Endpoint{ + { + DNSName: "bar.test-zone.example.org", + Targets: endpoint.Targets{"8.8.8.8"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + }, + } + + r, _ := NewTXTRegistry(p, "", "txt%{record_type}", "owner", time.Hour, "wc", []string{}, []string{}, false, nil) + records, _ := r.Records(ctx) + + assert.True(t, testutils.SameEndpoints(records, expectedRecords)) + + r, _ = NewTXTRegistry(p, "", "TxT%{record_type}", "owner", time.Hour, "wc", []string{}, []string{}, false, nil) + records, _ = r.Records(ctx) + + assert.True(t, testutils.SameEndpoints(records, expectedRecords)) +} + +func testTXTRegistryApplyChanges(t *testing.T) { + t.Run("With Prefix", testTXTRegistryApplyChangesWithPrefix) + t.Run("With Templated Prefix", testTXTRegistryApplyChangesWithTemplatedPrefix) + t.Run("With Templated Suffix", testTXTRegistryApplyChangesWithTemplatedSuffix) + t.Run("With Suffix", testTXTRegistryApplyChangesWithSuffix) + t.Run("No prefix", testTXTRegistryApplyChangesNoPrefix) +} + +func testTXTRegistryApplyChangesWithPrefix(t *testing.T) { + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + ctxEndpoints := []*endpoint.Endpoint{} + ctx := context.WithValue(context.Background(), provider.RecordsContextKey, ctxEndpoints) + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) + } + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("txt.bar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("txt.bar.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("txt.tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("txt.cname-tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("txt.foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("txt.cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-1"), + newEndpointWithOwner("txt.multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-1"), + newEndpointWithOwner("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-1"), + newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-2"), + newEndpointWithOwner("txt.multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), + newEndpointWithOwner("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), + }, + }) + r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "", []string{}, []string{}, false, nil) + + changes := &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "", "ingress/default/my-ingress"), + newEndpointWithOwnerResource("multiple.test-zone.example.org", "lb3.loadbalancer.com", endpoint.RecordTypeCNAME, "", "ingress/default/my-ingress").WithSetIdentifier("test-set-3"), + newEndpointWithOwnerResource("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "", "ingress/default/my-ingress"), + }, + Delete: []*endpoint.Endpoint{ + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-1"), + }, + UpdateNew: []*endpoint.Endpoint{ + newEndpointWithOwnerResource("tar.test-zone.example.org", "new-tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress-2"), + newEndpointWithOwnerResource("multiple.test-zone.example.org", "new.loadbalancer.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress-2").WithSetIdentifier("test-set-2"), + }, + UpdateOld: []*endpoint.Endpoint{ + newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-2"), + }, + } + expected := &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress"), + newEndpointWithOwnerAndOwnedRecord("txt.new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "new-record-1.test-zone.example.org"), + newEndpointWithOwnerAndOwnedRecord("txt.cname-new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "new-record-1.test-zone.example.org"), + newEndpointWithOwnerResource("multiple.test-zone.example.org", "lb3.loadbalancer.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress").WithSetIdentifier("test-set-3"), + newEndpointWithOwnerAndOwnedRecord("txt.multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-3"), + newEndpointWithOwnerAndOwnedRecord("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-3"), + newEndpointWithOwnerResource("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress"), + newEndpointWithOwnerAndOwnedRecord("txt.example", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "example"), + newEndpointWithOwnerAndOwnedRecord("txt.cname-example", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "example"), + }, + Delete: []*endpoint.Endpoint{ + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwnerAndOwnedRecord("txt.foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "foobar.test-zone.example.org"), + newEndpointWithOwnerAndOwnedRecord("txt.cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "foobar.test-zone.example.org"), + newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-1"), + newEndpointWithOwnerAndOwnedRecord("txt.multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-1"), + newEndpointWithOwnerAndOwnedRecord("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-1"), + }, + UpdateNew: []*endpoint.Endpoint{ + newEndpointWithOwnerResource("tar.test-zone.example.org", "new-tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress-2"), + newEndpointWithOwnerAndOwnedRecord("txt.tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\"", endpoint.RecordTypeTXT, "", "tar.test-zone.example.org"), + newEndpointWithOwnerAndOwnedRecord("txt.cname-tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\"", endpoint.RecordTypeTXT, "", "tar.test-zone.example.org"), + newEndpointWithOwnerResource("multiple.test-zone.example.org", "new.loadbalancer.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress-2").WithSetIdentifier("test-set-2"), + newEndpointWithOwnerAndOwnedRecord("txt.multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-2"), + newEndpointWithOwnerAndOwnedRecord("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-2"), + }, + UpdateOld: []*endpoint.Endpoint{ + newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwnerAndOwnedRecord("txt.tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "tar.test-zone.example.org"), + newEndpointWithOwnerAndOwnedRecord("txt.cname-tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "tar.test-zone.example.org"), + newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-2"), + newEndpointWithOwnerAndOwnedRecord("txt.multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-2"), + newEndpointWithOwnerAndOwnedRecord("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-2"), + }, + } + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + mExpected := map[string][]*endpoint.Endpoint{ + "Create": expected.Create, + "UpdateNew": expected.UpdateNew, + "UpdateOld": expected.UpdateOld, + "Delete": expected.Delete, + } + mGot := map[string][]*endpoint.Endpoint{ + "Create": got.Create, + "UpdateNew": got.UpdateNew, + "UpdateOld": got.UpdateOld, + "Delete": got.Delete, + } + assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) + assert.Equal(t, nil, ctx.Value(provider.RecordsContextKey)) + } + err := r.ApplyChanges(ctx, changes) + require.NoError(t, err) +} + +func testTXTRegistryApplyChangesWithTemplatedPrefix(t *testing.T) { + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + ctxEndpoints := []*endpoint.Endpoint{} + ctx := context.WithValue(context.Background(), provider.RecordsContextKey, ctxEndpoints) + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) + } + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{}, + }) + r, _ := NewTXTRegistry(p, "prefix%{record_type}.", "", "owner", time.Hour, "", []string{}, []string{}, false, nil) + changes := &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "", "ingress/default/my-ingress"), + }, + Delete: []*endpoint.Endpoint{}, + UpdateOld: []*endpoint.Endpoint{}, + UpdateNew: []*endpoint.Endpoint{}, + } + expected := &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress"), + newEndpointWithOwnerAndOwnedRecord("prefixcname.new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "new-record-1.test-zone.example.org"), + }, + } + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + mExpected := map[string][]*endpoint.Endpoint{ + "Create": expected.Create, + "UpdateNew": expected.UpdateNew, + "UpdateOld": expected.UpdateOld, + "Delete": expected.Delete, + } + mGot := map[string][]*endpoint.Endpoint{ + "Create": got.Create, + "UpdateNew": got.UpdateNew, + "UpdateOld": got.UpdateOld, + "Delete": got.Delete, + } + assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) + assert.Equal(t, nil, ctx.Value(provider.RecordsContextKey)) + } + err := r.ApplyChanges(ctx, changes) + require.NoError(t, err) +} + +func testTXTRegistryApplyChangesWithTemplatedSuffix(t *testing.T) { + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + ctxEndpoints := []*endpoint.Endpoint{} + ctx := context.WithValue(context.Background(), provider.RecordsContextKey, ctxEndpoints) + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) + } + r, _ := NewTXTRegistry(p, "", "-%{record_type}suffix", "owner", time.Hour, "", []string{}, []string{}, false, nil) + changes := &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "", "ingress/default/my-ingress"), + }, + Delete: []*endpoint.Endpoint{}, + UpdateOld: []*endpoint.Endpoint{}, + UpdateNew: []*endpoint.Endpoint{}, + } + expected := &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress"), + newEndpointWithOwnerAndOwnedRecord("new-record-1-cnamesuffix.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "new-record-1.test-zone.example.org"), + }, + } + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + mExpected := map[string][]*endpoint.Endpoint{ + "Create": expected.Create, + "UpdateNew": expected.UpdateNew, + "UpdateOld": expected.UpdateOld, + "Delete": expected.Delete, + } + mGot := map[string][]*endpoint.Endpoint{ + "Create": got.Create, + "UpdateNew": got.UpdateNew, + "UpdateOld": got.UpdateOld, + "Delete": got.Delete, + } + assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) + assert.Equal(t, nil, ctx.Value(provider.RecordsContextKey)) + } + err := r.ApplyChanges(ctx, changes) + require.NoError(t, err) +} + +func testTXTRegistryApplyChangesWithSuffix(t *testing.T) { + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + ctxEndpoints := []*endpoint.Endpoint{} + ctx := context.WithValue(context.Background(), provider.RecordsContextKey, ctxEndpoints) + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) + } + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("bar-txt.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("bar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("cname-bar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("tar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("cname-tar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("foobar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("cname-foobar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-1"), + newEndpointWithOwner("multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-1"), + newEndpointWithOwner("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-1"), + newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-2"), + newEndpointWithOwner("multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), + newEndpointWithOwner("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), + }, + }) + r, _ := NewTXTRegistry(p, "", "-txt", "owner", time.Hour, "wildcard", []string{}, []string{}, false, nil) + + changes := &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "", "ingress/default/my-ingress"), + newEndpointWithOwnerResource("multiple.test-zone.example.org", "lb3.loadbalancer.com", endpoint.RecordTypeCNAME, "", "ingress/default/my-ingress").WithSetIdentifier("test-set-3"), + newEndpointWithOwnerResource("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "", "ingress/default/my-ingress"), + newEndpointWithOwnerResource("*.wildcard.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "", "ingress/default/my-ingress"), + }, + Delete: []*endpoint.Endpoint{ + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-1"), + }, + UpdateNew: []*endpoint.Endpoint{ + newEndpointWithOwnerResource("tar.test-zone.example.org", "new-tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress-2"), + newEndpointWithOwnerResource("multiple.test-zone.example.org", "new.loadbalancer.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress-2").WithSetIdentifier("test-set-2"), + }, + UpdateOld: []*endpoint.Endpoint{ + newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-2"), + }, + } + expected := &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress"), + newEndpointWithOwnerAndOwnedRecord("new-record-1-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "new-record-1.test-zone.example.org"), + newEndpointWithOwnerAndOwnedRecord("cname-new-record-1-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "new-record-1.test-zone.example.org"), + newEndpointWithOwnerResource("multiple.test-zone.example.org", "lb3.loadbalancer.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress").WithSetIdentifier("test-set-3"), + newEndpointWithOwnerAndOwnedRecord("multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-3"), + newEndpointWithOwnerAndOwnedRecord("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-3"), + newEndpointWithOwnerResource("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress"), + newEndpointWithOwnerAndOwnedRecord("example-txt", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "example"), + newEndpointWithOwnerAndOwnedRecord("cname-example-txt", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "example"), + newEndpointWithOwnerResource("*.wildcard.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress"), + newEndpointWithOwnerAndOwnedRecord("wildcard-txt.wildcard.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "*.wildcard.test-zone.example.org"), + newEndpointWithOwnerAndOwnedRecord("cname-wildcard-txt.wildcard.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, "", "*.wildcard.test-zone.example.org"), + }, + Delete: []*endpoint.Endpoint{ + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwnerAndOwnedRecord("foobar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "foobar.test-zone.example.org"), + newEndpointWithOwnerAndOwnedRecord("cname-foobar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "foobar.test-zone.example.org"), + newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-1"), + newEndpointWithOwnerAndOwnedRecord("multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-1"), + newEndpointWithOwnerAndOwnedRecord("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-1"), + }, + UpdateNew: []*endpoint.Endpoint{ + newEndpointWithOwnerResource("tar.test-zone.example.org", "new-tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress-2"), + newEndpointWithOwnerAndOwnedRecord("tar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\"", endpoint.RecordTypeTXT, "", "tar.test-zone.example.org"), + newEndpointWithOwnerAndOwnedRecord("cname-tar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\"", endpoint.RecordTypeTXT, "", "tar.test-zone.example.org"), + newEndpointWithOwnerResource("multiple.test-zone.example.org", "new.loadbalancer.com", endpoint.RecordTypeCNAME, "owner", "ingress/default/my-ingress-2").WithSetIdentifier("test-set-2"), + newEndpointWithOwnerAndOwnedRecord("multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-2"), + newEndpointWithOwnerAndOwnedRecord("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-2"), + }, + UpdateOld: []*endpoint.Endpoint{ + newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwnerAndOwnedRecord("tar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "tar.test-zone.example.org"), + newEndpointWithOwnerAndOwnedRecord("cname-tar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "tar.test-zone.example.org"), + newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-2"), + newEndpointWithOwnerAndOwnedRecord("multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-2"), + newEndpointWithOwnerAndOwnedRecord("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "multiple.test-zone.example.org").WithSetIdentifier("test-set-2"), + }, + } + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + mExpected := map[string][]*endpoint.Endpoint{ + "Create": expected.Create, + "UpdateNew": expected.UpdateNew, + "UpdateOld": expected.UpdateOld, + "Delete": expected.Delete, + } + mGot := map[string][]*endpoint.Endpoint{ + "Create": got.Create, + "UpdateNew": got.UpdateNew, + "UpdateOld": got.UpdateOld, + "Delete": got.Delete, + } + assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) + assert.Equal(t, nil, ctx.Value(provider.RecordsContextKey)) + } + err := r.ApplyChanges(ctx, changes) + require.NoError(t, err) +} + +func testTXTRegistryApplyChangesNoPrefix(t *testing.T) { + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + ctxEndpoints := []*endpoint.Endpoint{} + ctx := context.WithValue(context.Background(), provider.RecordsContextKey, ctxEndpoints) + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) + } + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("txt.bar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("txt.bar.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("txt.tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + }, + }) + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil) + + changes := &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("new-alias.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "").WithProviderSpecific("alias", "true"), + }, + Delete: []*endpoint.Endpoint{ + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), + }, + UpdateNew: []*endpoint.Endpoint{ + newEndpointWithOwner("tar.test-zone.example.org", "new-tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner-2"), + }, + UpdateOld: []*endpoint.Endpoint{ + newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner-2"), + }, + } + expected := &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwnerAndOwnedRecord("new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "new-record-1.test-zone.example.org"), + newEndpointWithOwnerAndOwnedRecord("cname-new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "new-record-1.test-zone.example.org"), + newEndpointWithOwner("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwnerAndOwnedRecord("example", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "example"), + newEndpointWithOwnerAndOwnedRecord("cname-example", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "example"), + newEndpointWithOwner("new-alias.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "owner").WithProviderSpecific("alias", "true"), + // TODO: It's not clear why the TXT registry copies ProviderSpecificProperties to ownership records; that doesn't seem correct. + newEndpointWithOwnerAndOwnedRecord("new-alias.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "new-alias.test-zone.example.org").WithProviderSpecific("alias", "true"), + newEndpointWithOwnerAndOwnedRecord("cname-new-alias.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "new-alias.test-zone.example.org").WithProviderSpecific("alias", "true"), + }, + Delete: []*endpoint.Endpoint{ + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwnerAndOwnedRecord("foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "foobar.test-zone.example.org"), + newEndpointWithOwnerAndOwnedRecord("cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "foobar.test-zone.example.org"), + }, + UpdateNew: []*endpoint.Endpoint{}, + UpdateOld: []*endpoint.Endpoint{}, + } + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + mExpected := map[string][]*endpoint.Endpoint{ + "Create": expected.Create, + "UpdateNew": expected.UpdateNew, + "UpdateOld": expected.UpdateOld, + "Delete": expected.Delete, + } + mGot := map[string][]*endpoint.Endpoint{ + "Create": got.Create, + "UpdateNew": got.UpdateNew, + "UpdateOld": got.UpdateOld, + "Delete": got.Delete, + } + assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) + assert.Equal(t, nil, ctx.Value(provider.RecordsContextKey)) + } + err := r.ApplyChanges(ctx, changes) + require.NoError(t, err) +} + +func testTXTRegistryMissingRecords(t *testing.T) { + t.Run("No prefix", testTXTRegistryMissingRecordsNoPrefix) + t.Run("With Prefix", testTXTRegistryMissingRecordsWithPrefix) +} + +func testTXTRegistryMissingRecordsNoPrefix(t *testing.T) { + ctx := context.Background() + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("oldformat.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("oldformat.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("oldformat2.test-zone.example.org", "bar.loadbalancer.com", endpoint.RecordTypeA, ""), + newEndpointWithOwner("oldformat2.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("newformat.test-zone.example.org", "foobar.nameserver.com", endpoint.RecordTypeNS, ""), + newEndpointWithOwner("ns-newformat.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("newformat.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("noheritage.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("oldformat-otherowner.test-zone.example.org", "bar.loadbalancer.com", endpoint.RecordTypeA, ""), + newEndpointWithOwner("oldformat-otherowner.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=otherowner\"", endpoint.RecordTypeTXT, ""), + endpoint.NewEndpoint("unmanaged1.test-zone.example.org", endpoint.RecordTypeA, "unmanaged1.loadbalancer.com"), + endpoint.NewEndpoint("unmanaged2.test-zone.example.org", endpoint.RecordTypeCNAME, "unmanaged2.loadbalancer.com"), + newEndpointWithOwner("this-is-a-63-characters-long-label-that-we-do-expect-will-work.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("this-is-a-63-characters-long-label-that-we-do-expect-will-work.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + }, + }) + expectedRecords := []*endpoint.Endpoint{ + { + DNSName: "oldformat.test-zone.example.org", + Targets: endpoint.Targets{"foo.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + // owner was added from the TXT record's target + endpoint.OwnerLabelKey: "owner", + }, + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + { + Name: "txt/force-update", + Value: "true", + }, + }, + }, + { + DNSName: "oldformat2.test-zone.example.org", + Targets: endpoint.Targets{"bar.loadbalancer.com"}, + RecordType: endpoint.RecordTypeA, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + { + Name: "txt/force-update", + Value: "true", + }, + }, + }, + { + DNSName: "newformat.test-zone.example.org", + Targets: endpoint.Targets{"foobar.nameserver.com"}, + RecordType: endpoint.RecordTypeNS, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + }, + // Only TXT records with the wrong heritage are returned by Records() + { + DNSName: "noheritage.test-zone.example.org", + Targets: endpoint.Targets{"random"}, + RecordType: endpoint.RecordTypeTXT, + Labels: map[string]string{ + // No owner because it's not external-dns heritage + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "oldformat-otherowner.test-zone.example.org", + Targets: endpoint.Targets{"bar.loadbalancer.com"}, + RecordType: endpoint.RecordTypeA, + Labels: map[string]string{ + // Records() retrieves all the records of the zone, no matter the owner + endpoint.OwnerLabelKey: "otherowner", + }, + }, + { + DNSName: "unmanaged1.test-zone.example.org", + Targets: endpoint.Targets{"unmanaged1.loadbalancer.com"}, + RecordType: endpoint.RecordTypeA, + }, + { + DNSName: "unmanaged2.test-zone.example.org", + Targets: endpoint.Targets{"unmanaged2.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + { + DNSName: "this-is-a-63-characters-long-label-that-we-do-expect-will-work.test-zone.example.org", + Targets: endpoint.Targets{"foo.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + }, + } + + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "wc", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS}, []string{}, false, nil) + records, _ := r.Records(ctx) + + assert.True(t, testutils.SameEndpoints(records, expectedRecords)) +} + +func testTXTRegistryMissingRecordsWithPrefix(t *testing.T) { + ctx := context.Background() + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("oldformat.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("txt.oldformat.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("oldformat2.test-zone.example.org", "bar.loadbalancer.com", endpoint.RecordTypeA, ""), + newEndpointWithOwner("txt.oldformat2.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("newformat.test-zone.example.org", "foobar.nameserver.com", endpoint.RecordTypeNS, ""), + newEndpointWithOwner("txt.ns-newformat.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("oldformat3.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("txt.oldformat3.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("txt.newformat.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("noheritage.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("oldformat-otherowner.test-zone.example.org", "bar.loadbalancer.com", endpoint.RecordTypeA, ""), + newEndpointWithOwner("txt.oldformat-otherowner.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=otherowner\"", endpoint.RecordTypeTXT, ""), + endpoint.NewEndpoint("unmanaged1.test-zone.example.org", endpoint.RecordTypeA, "unmanaged1.loadbalancer.com"), + endpoint.NewEndpoint("unmanaged2.test-zone.example.org", endpoint.RecordTypeCNAME, "unmanaged2.loadbalancer.com"), + }, + }) + expectedRecords := []*endpoint.Endpoint{ + { + DNSName: "oldformat.test-zone.example.org", + Targets: endpoint.Targets{"foo.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{ + // owner was added from the TXT record's target + endpoint.OwnerLabelKey: "owner", + }, + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + { + Name: "txt/force-update", + Value: "true", + }, + }, + }, + { + DNSName: "oldformat2.test-zone.example.org", + Targets: endpoint.Targets{"bar.loadbalancer.com"}, + RecordType: endpoint.RecordTypeA, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + { + Name: "txt/force-update", + Value: "true", + }, + }, + }, + { + DNSName: "oldformat3.test-zone.example.org", + Targets: endpoint.Targets{"random"}, + RecordType: endpoint.RecordTypeTXT, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + ProviderSpecific: []endpoint.ProviderSpecificProperty{ + { + Name: "txt/force-update", + Value: "true", + }, + }, + }, + { + DNSName: "newformat.test-zone.example.org", + Targets: endpoint.Targets{"foobar.nameserver.com"}, + RecordType: endpoint.RecordTypeNS, + Labels: map[string]string{ + endpoint.OwnerLabelKey: "owner", + }, + }, + { + DNSName: "noheritage.test-zone.example.org", + Targets: endpoint.Targets{"random"}, + RecordType: endpoint.RecordTypeTXT, + Labels: map[string]string{ + // No owner because it's not external-dns heritage + endpoint.OwnerLabelKey: "", + }, + }, + { + DNSName: "oldformat-otherowner.test-zone.example.org", + Targets: endpoint.Targets{"bar.loadbalancer.com"}, + RecordType: endpoint.RecordTypeA, + Labels: map[string]string{ + // All the records of the zone are retrieved, no matter the owner + endpoint.OwnerLabelKey: "otherowner", + }, + }, + { + DNSName: "unmanaged1.test-zone.example.org", + Targets: endpoint.Targets{"unmanaged1.loadbalancer.com"}, + RecordType: endpoint.RecordTypeA, + }, + { + DNSName: "unmanaged2.test-zone.example.org", + Targets: endpoint.Targets{"unmanaged2.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + } + + r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "wc", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS, endpoint.RecordTypeTXT}, []string{}, false, nil) + records, _ := r.Records(ctx) + + assert.True(t, testutils.SameEndpoints(records, expectedRecords)) +} + +func TestCacheMethods(t *testing.T) { + cache := []*endpoint.Endpoint{ + newEndpointWithOwner("thing.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing1.com", "1.2.3.6", "A", "owner"), + newEndpointWithOwner("thing2.com", "1.2.3.4", "CNAME", "owner"), + newEndpointWithOwner("thing3.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing4.com", "1.2.3.4", "A", "owner"), + } + registry := &TXTRegistry{ + recordsCache: cache, + cacheInterval: time.Hour, + } + + expectedCacheAfterAdd := []*endpoint.Endpoint{ + newEndpointWithOwner("thing.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing1.com", "1.2.3.6", "A", "owner"), + newEndpointWithOwner("thing2.com", "1.2.3.4", "CNAME", "owner"), + newEndpointWithOwner("thing3.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing4.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing4.com", "2001:DB8::1", "AAAA", "owner"), + newEndpointWithOwner("thing5.com", "1.2.3.5", "A", "owner"), + } + + expectedCacheAfterUpdate := []*endpoint.Endpoint{ + newEndpointWithOwner("thing1.com", "1.2.3.6", "A", "owner"), + newEndpointWithOwner("thing2.com", "1.2.3.4", "CNAME", "owner"), + newEndpointWithOwner("thing3.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing4.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing5.com", "1.2.3.5", "A", "owner"), + newEndpointWithOwner("thing.com", "1.2.3.6", "A", "owner2"), + newEndpointWithOwner("thing4.com", "2001:DB8::2", "AAAA", "owner"), + } + + expectedCacheAfterDelete := []*endpoint.Endpoint{ + newEndpointWithOwner("thing1.com", "1.2.3.6", "A", "owner"), + newEndpointWithOwner("thing2.com", "1.2.3.4", "CNAME", "owner"), + newEndpointWithOwner("thing3.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing4.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing5.com", "1.2.3.5", "A", "owner"), + } + // test add cache + registry.addToCache(newEndpointWithOwner("thing4.com", "2001:DB8::1", "AAAA", "owner")) + registry.addToCache(newEndpointWithOwner("thing5.com", "1.2.3.5", "A", "owner")) + + if !reflect.DeepEqual(expectedCacheAfterAdd, registry.recordsCache) { + t.Fatalf("expected endpoints should match endpoints from cache: expected %v, but got %v", expectedCacheAfterAdd, registry.recordsCache) + } + + // test update cache + registry.removeFromCache(newEndpointWithOwner("thing.com", "1.2.3.4", "A", "owner")) + registry.addToCache(newEndpointWithOwner("thing.com", "1.2.3.6", "A", "owner2")) + registry.removeFromCache(newEndpointWithOwner("thing4.com", "2001:DB8::1", "AAAA", "owner")) + registry.addToCache(newEndpointWithOwner("thing4.com", "2001:DB8::2", "AAAA", "owner")) + // ensure it was updated + if !reflect.DeepEqual(expectedCacheAfterUpdate, registry.recordsCache) { + t.Fatalf("expected endpoints should match endpoints from cache: expected %v, but got %v", expectedCacheAfterUpdate, registry.recordsCache) + } + + // test deleting a record + registry.removeFromCache(newEndpointWithOwner("thing.com", "1.2.3.6", "A", "owner2")) + registry.removeFromCache(newEndpointWithOwner("thing4.com", "2001:DB8::2", "AAAA", "owner")) + // ensure it was deleted + if !reflect.DeepEqual(expectedCacheAfterDelete, registry.recordsCache) { + t.Fatalf("expected endpoints should match endpoints from cache: expected %v, but got %v", expectedCacheAfterDelete, registry.recordsCache) + } +} + +func TestDropPrefix(t *testing.T) { + mapper := newaffixNameMapper("foo-%{record_type}-", "", "") + expectedOutput := "test.example.com" + + tests := []string{ + "foo-cname-test.example.com", + "foo-a-test.example.com", + "foo--test.example.com", + } + + for _, tc := range tests { + t.Run(tc, func(t *testing.T) { + actualOutput, _ := mapper.dropAffixExtractType(tc) + assert.Equal(t, expectedOutput, actualOutput) + }) + } +} + +func TestDropSuffix(t *testing.T) { + mapper := newaffixNameMapper("", "-%{record_type}-foo", "") + expectedOutput := "test.example.com" + + tests := []string{ + "test-a-foo.example.com", + "test--foo.example.com", + } + + for _, tc := range tests { + t.Run(tc, func(t *testing.T) { + r := strings.SplitN(tc, ".", 2) + rClean, _ := mapper.dropAffixExtractType(r[0]) + actualOutput := rClean + "." + r[1] + assert.Equal(t, expectedOutput, actualOutput) + }) + } +} + +func TestExtractRecordTypeDefaultPosition(t *testing.T) { + tests := []struct { + input string + expectedName string + expectedType string + }{ + { + input: "ns-zone.example.com", + expectedName: "zone.example.com", + expectedType: "NS", + }, + { + input: "aaaa-zone.example.com", + expectedName: "zone.example.com", + expectedType: "AAAA", + }, + { + input: "ptr-zone.example.com", + expectedName: "ptr-zone.example.com", + expectedType: "", + }, + { + input: "zone.example.com", + expectedName: "zone.example.com", + expectedType: "", + }, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + actualName, actualType := extractRecordTypeDefaultPosition(tc.input) + assert.Equal(t, tc.expectedName, actualName) + assert.Equal(t, tc.expectedType, actualType) + }) + } +} + +func TestToEndpointNameNewTXT(t *testing.T) { + tests := []struct { + name string + mapper affixNameMapper + domain string + txtDomain string + recordType string + }{ + { + name: "prefix", + mapper: newaffixNameMapper("foo", "", ""), + domain: "example.com", + recordType: "A", + txtDomain: "fooa-example.com", + }, + { + name: "suffix", + mapper: newaffixNameMapper("", "foo", ""), + domain: "example.com", + recordType: "AAAA", + txtDomain: "aaaa-examplefoo.com", + }, + { + name: "prefix with dash", + mapper: newaffixNameMapper("foo-", "", ""), + domain: "example.com", + recordType: "A", + txtDomain: "foo-a-example.com", + }, + { + name: "suffix with dash", + mapper: newaffixNameMapper("", "-foo", ""), + domain: "example.com", + recordType: "CNAME", + txtDomain: "cname-example-foo.com", + }, + { + name: "prefix with dot", + mapper: newaffixNameMapper("foo.", "", ""), + domain: "example.com", + recordType: "CNAME", + txtDomain: "foo.cname-example.com", + }, + { + name: "suffix with dot", + mapper: newaffixNameMapper("", ".foo", ""), + domain: "example.com", + recordType: "CNAME", + txtDomain: "cname-example.foo.com", + }, + { + name: "prefix with multiple dots", + mapper: newaffixNameMapper("foo.bar.", "", ""), + domain: "example.com", + recordType: "CNAME", + txtDomain: "foo.bar.cname-example.com", + }, + { + name: "suffix with multiple dots", + mapper: newaffixNameMapper("", ".foo.bar.test", ""), + domain: "example.com", + recordType: "CNAME", + txtDomain: "cname-example.foo.bar.test.com", + }, + { + name: "templated prefix", + mapper: newaffixNameMapper("%{record_type}-foo", "", ""), + domain: "example.com", + recordType: "A", + txtDomain: "a-fooexample.com", + }, + { + name: "templated suffix", + mapper: newaffixNameMapper("", "foo-%{record_type}", ""), + domain: "example.com", + recordType: "A", + txtDomain: "examplefoo-a.com", + }, + { + name: "templated prefix with dot", + mapper: newaffixNameMapper("%{record_type}foo.", "", ""), + domain: "example.com", + recordType: "CNAME", + txtDomain: "cnamefoo.example.com", + }, + { + name: "templated suffix with dot", + mapper: newaffixNameMapper("", ".foo%{record_type}", ""), + domain: "example.com", + recordType: "A", + txtDomain: "example.fooa.com", + }, + { + name: "templated prefix with multiple dots", + mapper: newaffixNameMapper("bar.%{record_type}.foo.", "", ""), + domain: "example.com", + recordType: "CNAME", + txtDomain: "bar.cname.foo.example.com", + }, + { + name: "templated suffix with multiple dots", + mapper: newaffixNameMapper("", ".foo%{record_type}.bar", ""), + domain: "example.com", + recordType: "A", + txtDomain: "example.fooa.bar.com", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + txtDomain := tc.mapper.toNewTXTName(tc.domain, tc.recordType) + assert.Equal(t, tc.txtDomain, txtDomain) + + domain, _ := tc.mapper.toEndpointName(txtDomain) + assert.Equal(t, tc.domain, domain) + }) + } +} + +func TestNewTXTScheme(t *testing.T) { + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + ctxEndpoints := []*endpoint.Endpoint{} + ctx := context.WithValue(context.Background(), provider.RecordsContextKey, ctxEndpoints) + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) + } + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("txt.bar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("txt.bar.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("txt.tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), + }, + }) + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil) + + changes := &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwner("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""), + }, + Delete: []*endpoint.Endpoint{ + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), + }, + UpdateNew: []*endpoint.Endpoint{ + newEndpointWithOwner("tar.test-zone.example.org", "new-tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner-2"), + }, + UpdateOld: []*endpoint.Endpoint{ + newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner-2"), + }, + } + expected := &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwnerAndOwnedRecord("new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "new-record-1.test-zone.example.org"), + newEndpointWithOwnerAndOwnedRecord("cname-new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "new-record-1.test-zone.example.org"), + newEndpointWithOwner("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwnerAndOwnedRecord("example", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "example"), + newEndpointWithOwnerAndOwnedRecord("cname-example", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "example"), + }, + Delete: []*endpoint.Endpoint{ + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwnerAndOwnedRecord("foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "foobar.test-zone.example.org"), + newEndpointWithOwnerAndOwnedRecord("cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "foobar.test-zone.example.org"), + }, + UpdateNew: []*endpoint.Endpoint{}, + UpdateOld: []*endpoint.Endpoint{}, + } + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + mExpected := map[string][]*endpoint.Endpoint{ + "Create": expected.Create, + "UpdateNew": expected.UpdateNew, + "UpdateOld": expected.UpdateOld, + "Delete": expected.Delete, + } + mGot := map[string][]*endpoint.Endpoint{ + "Create": got.Create, + "UpdateNew": got.UpdateNew, + "UpdateOld": got.UpdateOld, + "Delete": got.Delete, + } + assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) + assert.Equal(t, nil, ctx.Value(provider.RecordsContextKey)) + } + err := r.ApplyChanges(ctx, changes) + require.NoError(t, err) +} + +func TestGenerateTXT(t *testing.T) { + record := newEndpointWithOwner("foo.test-zone.example.org", "new-foo.loadbalancer.com", endpoint.RecordTypeCNAME, "owner") + expectedTXT := []*endpoint.Endpoint{ + { + DNSName: "foo.test-zone.example.org", + Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=owner\""}, + RecordType: endpoint.RecordTypeTXT, + Labels: map[string]string{ + endpoint.OwnedRecordLabelKey: "foo.test-zone.example.org", + }, + }, + { + DNSName: "cname-foo.test-zone.example.org", + Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=owner\""}, + RecordType: endpoint.RecordTypeTXT, + Labels: map[string]string{ + endpoint.OwnedRecordLabelKey: "foo.test-zone.example.org", + }, + }, + } + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil) + gotTXT := r.generateTXTRecord(record) + assert.Equal(t, expectedTXT, gotTXT) +} + +func TestGenerateTXTForAAAA(t *testing.T) { + record := newEndpointWithOwner("foo.test-zone.example.org", "2001:DB8::1", endpoint.RecordTypeAAAA, "owner") + expectedTXT := []*endpoint.Endpoint{ + { + DNSName: "aaaa-foo.test-zone.example.org", + Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=owner\""}, + RecordType: endpoint.RecordTypeTXT, + Labels: map[string]string{ + endpoint.OwnedRecordLabelKey: "foo.test-zone.example.org", + }, + }, + } + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil) + gotTXT := r.generateTXTRecord(record) + assert.Equal(t, expectedTXT, gotTXT) +} + +func TestFailGenerateTXT(t *testing.T) { + + cnameRecord := &endpoint.Endpoint{ + DNSName: "foo-some-really-big-name-not-supported-and-will-fail-000000000000000000.test-zone.example.org", + Targets: endpoint.Targets{"new-foo.loadbalancer.com"}, + RecordType: endpoint.RecordTypeCNAME, + Labels: map[string]string{}, + } + // A bad DNS name returns empty expected TXT + expectedTXT := []*endpoint.Endpoint{} + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil) + gotTXT := r.generateTXTRecord(cnameRecord) + assert.Equal(t, expectedTXT, gotTXT) +} + +func TestTXTRegistryApplyChangesEncrypt(t *testing.T) { + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + ctxEndpoints := []*endpoint.Endpoint{} + ctx := context.WithValue(context.Background(), provider.RecordsContextKey, ctxEndpoints) + + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{ + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), + newEndpointWithOwnerAndOwnedRecord("txt.cname-foobar.test-zone.example.org", "\"h8UQ6jelUFUsEIn7SbFktc2MYXPx/q8lySqI4VwfVtVaIbb2nkHWV/88KKbuLtu7fJNzMir8ELVeVnRSY01KdiIuj7ledqZe5ailEjQaU5Z6uEKd5pgs6sH8\"", endpoint.RecordTypeTXT, "", "foobar.test-zone.example.org"), + }, + }) + + r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "", []string{}, []string{}, true, []byte("12345678901234567890123456789012")) + records, _ := r.Records(ctx) + changes := &plan.Changes{ + Delete: records, + } + + // ensure that encryption nonce gets reused when deleting records + expected := &plan.Changes{ + Delete: []*endpoint.Endpoint{ + newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), + newEndpointWithOwnerAndOwnedRecord("txt.cname-foobar.test-zone.example.org", "\"h8UQ6jelUFUsEIn7SbFktc2MYXPx/q8lySqI4VwfVtVaIbb2nkHWV/88KKbuLtu7fJNzMir8ELVeVnRSY01KdiIuj7ledqZe5ailEjQaU5Z6uEKd5pgs6sH8\"", endpoint.RecordTypeTXT, "", "foobar.test-zone.example.org"), + }, + } + + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + mExpected := map[string][]*endpoint.Endpoint{ + "Delete": expected.Delete, + } + mGot := map[string][]*endpoint.Endpoint{ + "Delete": got.Delete, + } + assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) + assert.Equal(t, nil, ctx.Value(provider.RecordsContextKey)) + } + err := r.ApplyChanges(ctx, changes) + require.NoError(t, err) +} + +// TestMultiClusterDifferentRecordTypeOwnership validates the registry handles environments where the same zone is managed by +// external-dns in different clusters and the ingress record type is different. For example one uses A records and the other +// uses CNAME. In this environment the first cluster that establishes the owner record should maintain ownership even +// if the same ingress host is deployed to the other. With the introduction of Dual Record support each record type +// was treated independently and would cause each cluster to fight over ownership. This tests ensure that the default +// Dual Stack record support only treats AAAA records independently and while keeping A and CNAME record ownership intact. +func TestMultiClusterDifferentRecordTypeOwnership(t *testing.T) { + ctx := context.Background() + p := inmemory.NewInMemoryProvider() + p.CreateZone(testZone) + p.ApplyChanges(ctx, &plan.Changes{ + Create: []*endpoint.Endpoint{ + // records on cluster using A record for ingress address + newEndpointWithOwner("bar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=cat,external-dns/resource=ingress/default/foo\"", endpoint.RecordTypeTXT, ""), + newEndpointWithOwner("bar.test-zone.example.org", "1.2.3.4", endpoint.RecordTypeA, ""), + }, + }) + + r, _ := NewTXTRegistry(p, "_owner.", "", "bar", time.Hour, "", []string{}, []string{}, false, nil) + records, _ := r.Records(ctx) + + // new cluster has same ingress host as other cluster and uses CNAME ingress address + cname := &endpoint.Endpoint{ + DNSName: "bar.test-zone.example.org", + Targets: endpoint.Targets{"cluster-b"}, + RecordType: "CNAME", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/foo-127", + }, + } + desired := []*endpoint.Endpoint{cname} + + pl := &plan.Plan{ + Policies: []plan.Policy{&plan.SyncPolicy{}}, + Current: records, + Desired: desired, + ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, + } + + changes := pl.Calculate() + p.OnApplyChanges = func(ctx context.Context, changes *plan.Changes) { + got := map[string][]*endpoint.Endpoint{ + "Create": changes.Create, + "UpdateNew": changes.UpdateNew, + "UpdateOld": changes.UpdateOld, + "Delete": changes.Delete, + } + expected := map[string][]*endpoint.Endpoint{ + "Create": {}, + "UpdateNew": {}, + "UpdateOld": {}, + "Delete": {}, + } + testutils.SamePlanChanges(got, expected) + } + + err := r.ApplyChanges(ctx, changes.Changes) + if err != nil { + t.Error(err) + } +} + +/** + +helper methods + +*/ + +func newEndpointWithOwner(dnsName, target, recordType, ownerID string) *endpoint.Endpoint { + return newEndpointWithOwnerAndLabels(dnsName, target, recordType, ownerID, nil) +} + +func newEndpointWithOwnerAndOwnedRecord(dnsName, target, recordType, ownerID, ownedRecord string) *endpoint.Endpoint { + return newEndpointWithOwnerAndLabels(dnsName, target, recordType, ownerID, endpoint.Labels{endpoint.OwnedRecordLabelKey: ownedRecord}) +} + +func newEndpointWithOwnerAndLabels(dnsName, target, recordType, ownerID string, labels endpoint.Labels) *endpoint.Endpoint { + e := endpoint.NewEndpoint(dnsName, recordType, target) + e.Labels[endpoint.OwnerLabelKey] = ownerID + for k, v := range labels { + e.Labels[k] = v + } + return e +} + +func newEndpointWithOwnerResource(dnsName, target, recordType, ownerID, resource string) *endpoint.Endpoint { + e := endpoint.NewEndpoint(dnsName, recordType, target) + e.Labels[endpoint.OwnerLabelKey] = ownerID + e.Labels[endpoint.ResourceLabelKey] = resource + return e +} diff --git a/internal/external-dns/testutils/endpoint.go b/internal/external-dns/testutils/endpoint.go new file mode 100644 index 00000000..f8b46579 --- /dev/null +++ b/internal/external-dns/testutils/endpoint.go @@ -0,0 +1,124 @@ +/* +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 testutils + +import ( + "reflect" + "sort" + + "sigs.k8s.io/external-dns/endpoint" +) + +/** test utility functions for endpoints verifications */ + +type byNames endpoint.ProviderSpecific + +func (p byNames) Len() int { return len(p) } +func (p byNames) Swap(i, j int) { p[i], p[j] = p[j], p[i] } +func (p byNames) Less(i, j int) bool { return p[i].Name < p[j].Name } + +type byAllFields []*endpoint.Endpoint + +func (b byAllFields) Len() int { return len(b) } +func (b byAllFields) Swap(i, j int) { b[i], b[j] = b[j], b[i] } +func (b byAllFields) Less(i, j int) bool { + if b[i].DNSName < b[j].DNSName { + return true + } + if b[i].DNSName == b[j].DNSName { + // This rather bad, we need a more complex comparison for Targets, which considers all elements + if b[i].Targets.Same(b[j].Targets) { + if b[i].RecordType == (b[j].RecordType) { + sa := b[i].ProviderSpecific + sb := b[j].ProviderSpecific + sort.Sort(byNames(sa)) + sort.Sort(byNames(sb)) + return reflect.DeepEqual(sa, sb) + } + return b[i].RecordType <= b[j].RecordType + } + return b[i].Targets.String() <= b[j].Targets.String() + } + return false +} + +// SameEndpoint returns true if two endpoints are same +// considers example.org. and example.org DNSName/Target as different endpoints +func SameEndpoint(a, b *endpoint.Endpoint) bool { + return a.DNSName == b.DNSName && a.Targets.Same(b.Targets) && a.RecordType == b.RecordType && a.SetIdentifier == b.SetIdentifier && + a.Labels[endpoint.OwnerLabelKey] == b.Labels[endpoint.OwnerLabelKey] && a.RecordTTL == b.RecordTTL && + a.Labels[endpoint.ResourceLabelKey] == b.Labels[endpoint.ResourceLabelKey] && + a.Labels[endpoint.OwnedRecordLabelKey] == b.Labels[endpoint.OwnedRecordLabelKey] && + SameProviderSpecific(a.ProviderSpecific, b.ProviderSpecific) +} + +// SameEndpoints compares two slices of endpoints regardless of order +// [x,y,z] == [z,x,y] +// [x,x,z] == [x,z,x] +// [x,y,y] != [x,x,y] +// [x,x,x] != [x,x,z] +func SameEndpoints(a, b []*endpoint.Endpoint) bool { + if len(a) != len(b) { + return false + } + + sa := a + sb := b + sort.Sort(byAllFields(sa)) + sort.Sort(byAllFields(sb)) + + for i := range sa { + if !SameEndpoint(sa[i], sb[i]) { + return false + } + } + return true +} + +// SameEndpointLabels verifies that labels of the two slices of endpoints are the same +func SameEndpointLabels(a, b []*endpoint.Endpoint) bool { + if len(a) != len(b) { + return false + } + + sa := a + sb := b + sort.Sort(byAllFields(sa)) + sort.Sort(byAllFields(sb)) + + for i := range sa { + if !reflect.DeepEqual(sa[i].Labels, sb[i].Labels) { + return false + } + } + return true +} + +// SamePlanChanges verifies that two set of changes are the same +func SamePlanChanges(a, b map[string][]*endpoint.Endpoint) bool { + return SameEndpoints(a["Create"], b["Create"]) && SameEndpoints(a["Delete"], b["Delete"]) && + SameEndpoints(a["UpdateOld"], b["UpdateOld"]) && SameEndpoints(a["UpdateNew"], b["UpdateNew"]) +} + +// SameProviderSpecific verifies that two maps contain the same string/string key/value pairs +func SameProviderSpecific(a, b endpoint.ProviderSpecific) bool { + sa := a + sb := b + sort.Sort(byNames(sa)) + sort.Sort(byNames(sb)) + return reflect.DeepEqual(sa, sb) +} diff --git a/internal/external-dns/testutils/endpoint_test.go b/internal/external-dns/testutils/endpoint_test.go new file mode 100644 index 00000000..efa472f8 --- /dev/null +++ b/internal/external-dns/testutils/endpoint_test.go @@ -0,0 +1,80 @@ +/* +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 testutils + +import ( + "fmt" + "sort" + "testing" + + "sigs.k8s.io/external-dns/endpoint" +) + +func TestExampleSameEndpoints(t *testing.T) { + eps := []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"load-balancer.org"}, + }, + { + DNSName: "example.org", + Targets: endpoint.Targets{"load-balancer.org"}, + RecordType: endpoint.RecordTypeTXT, + }, + { + DNSName: "abc.com", + Targets: endpoint.Targets{"something"}, + RecordType: endpoint.RecordTypeTXT, + }, + { + DNSName: "abc.com", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + SetIdentifier: "test-set-1", + }, + { + DNSName: "bbc.com", + Targets: endpoint.Targets{"foo.com"}, + RecordType: endpoint.RecordTypeCNAME, + }, + { + DNSName: "cbc.com", + Targets: endpoint.Targets{"foo.com"}, + RecordType: "CNAME", + RecordTTL: endpoint.TTL(60), + }, + { + DNSName: "example.org", + Targets: endpoint.Targets{"load-balancer.org"}, + ProviderSpecific: endpoint.ProviderSpecific{ + endpoint.ProviderSpecificProperty{Name: "foo", Value: "bar"}, + }, + }, + } + sort.Sort(byAllFields(eps)) + for _, ep := range eps { + fmt.Println(ep) + } + // Output: + // abc.com 0 IN A test-set-1 1.2.3.4 [] + // abc.com 0 IN TXT something [] + // bbc.com 0 IN CNAME foo.com [] + // cbc.com 60 IN CNAME foo.com [] + // example.org 0 IN load-balancer.org [] + // example.org 0 IN load-balancer.org [{foo bar}] + // example.org 0 IN TXT load-balancer.org [] +}