diff --git a/controller/execute.go b/controller/execute.go index d6b465526d..a9a747c61d 100644 --- a/controller/execute.go +++ b/controller/execute.go @@ -22,6 +22,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -426,7 +427,11 @@ func selectRegistry(cfg *externaldns.Config, p provider.Provider) (registry.Regi case "noop": r, err = registry.NewNoopRegistry(p) case "txt": - r, err = registry.NewTXTRegistry(p, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTOwnerID, cfg.TXTCacheInterval, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes, cfg.TXTEncryptEnabled, []byte(cfg.TXTEncryptAESKey), cfg.TXTOwnerOld) + overrides, err := parseTXTPrefixOverrides(cfg.TXTPrefixOverrides) + if err != nil { + return nil, err + } + r, err = registry.NewTXTRegistry(p, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTOwnerID, cfg.TXTCacheInterval, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes, cfg.TXTEncryptEnabled, []byte(cfg.TXTEncryptAESKey), cfg.TXTOwnerOld, overrides) case "aws-sd": r, err = registry.NewAWSSDRegistry(p, cfg.TXTOwnerID) default: @@ -463,6 +468,38 @@ func buildSource(ctx context.Context, cfg *externaldns.Config) (source.Source, e return wrappers.WrapSources(sources, opts) } +// parseTXTPrefixOverrides parses TXT prefix overrides from CLI flags +// Expects format "domain=prefix" and converts to map[string]string +// Returns error for invalid format entries +func parseTXTPrefixOverrides(overrides []string) (map[string]string, error) { + result := make(map[string]string, len(overrides)) + var errors []string + + for _, override := range overrides { + parts := strings.SplitN(override, "=", 2) + if len(parts) != 2 { + errors = append(errors, fmt.Sprintf("invalid format '%s': expected 'domain=prefix'", override)) + continue + } + + domain := strings.TrimSpace(parts[0]) + prefix := strings.TrimSpace(parts[1]) + + if domain == "" { + errors = append(errors, fmt.Sprintf("invalid format '%s': domain cannot be empty", override)) + continue + } + + result[domain] = prefix + } + + if len(errors) > 0 { + return nil, fmt.Errorf("invalid txt-prefix-override entries: %s", strings.Join(errors, ", ")) + } + + return result, nil +} + // RegexDomainFilter overrides DomainFilter func createDomainFilter(cfg *externaldns.Config) *endpoint.DomainFilter { if cfg.RegexDomainFilter != nil && cfg.RegexDomainFilter.String() != "" { diff --git a/controller/execute_test.go b/controller/execute_test.go index a3e5b461b5..8b0c5589ea 100644 --- a/controller/execute_test.go +++ b/controller/execute_test.go @@ -679,3 +679,92 @@ func TestControllerRunCancelContextStopsLoop(t *testing.T) { t.Fatal("controller did not stop after context cancellation") } } + +func TestParseTXTPrefixOverrides(t *testing.T) { + tests := []struct { + name string + input []string + expected map[string]string + expectError bool + }{ + { + name: "empty input should return empty map", + input: []string{}, + expected: map[string]string{}, + }, + { + name: "single override should parse correctly", + input: []string{"example.com=custom-prefix"}, + expected: map[string]string{"example.com": "custom-prefix"}, + }, + { + name: "multiple overrides should parse correctly", + input: []string{"example.com=custom-prefix", "test.com=another-prefix"}, + expected: map[string]string{"example.com": "custom-prefix", "test.com": "another-prefix"}, + }, + { + name: "override with template should parse correctly", + input: []string{"api.example.com=%{record_type}-api"}, + expected: map[string]string{"api.example.com": "%{record_type}-api"}, + }, + { + name: "multiple domains with different prefixes should parse correctly", + input: []string{"prod.example.com=prod", "staging.example.com=stg", "dev.example.com=dev"}, + expected: map[string]string{"prod.example.com": "prod", "staging.example.com": "stg", "dev.example.com": "dev"}, + }, + { + name: "domain with trailing dot should be handled correctly", + input: []string{"example.com.=prefix-with-dot"}, + expected: map[string]string{"example.com.": "prefix-with-dot"}, + }, + { + name: "prefix with special characters should be handled correctly", + input: []string{"api.example.com=api-%{record_type}-v2"}, + expected: map[string]string{"api.example.com": "api-%{record_type}-v2"}, + }, + { + name: "invalid format without equals should return error", + input: []string{"example.com"}, + expectError: true, + }, + { + name: "empty domain should return error", + input: []string{"=prefix"}, + expectError: true, + }, + { + name: "empty prefix should be accepted", + input: []string{"example.com="}, + expected: map[string]string{"example.com": ""}, + }, + { + name: "mixed valid and invalid entries should return error", + input: []string{"example.com=valid", "invalid-entry", "test.com=another-valid"}, + expectError: true, + }, + { + name: "whitespace should be trimmed correctly", + input: []string{" example.com = prefix-with-spaces "}, + expected: map[string]string{"example.com": "prefix-with-spaces"}, + }, + { + name: "duplicate domains should use last value", + input: []string{"example.com=first", "example.com=second"}, + expected: map[string]string{"example.com": "second"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := parseTXTPrefixOverrides(test.input) + + if test.expectError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } +} diff --git a/docs/flags.md b/docs/flags.md index 1b294dbea4..2724e5f356 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -163,6 +163,7 @@ | `--txt-owner-id="default"` | When using the TXT or DynamoDB registry, a name that identifies this instance of ExternalDNS (default: default) | | `--txt-prefix=""` | When using the TXT registry, a custom string that's prefixed to each ownership DNS record (optional). Could contain record type template like '%{record_type}-prefix-'. Mutual exclusive with txt-suffix! | | `--txt-suffix=""` | When using the TXT registry, a custom string that's suffixed to the host portion of each ownership DNS record (optional). Could contain record type template like '-%{record_type}-suffix'. Mutual exclusive with txt-prefix! | +| `--txt-prefix-override=TXT-PREFIX-OVERRIDE` | When using the TXT registry, specify domain-specific prefix overrides in the format 'domain=prefix' (optional). Useful for apex records that are difficult to create, e.g., 'example.com=%{record_type}.' for apex domains. Can be specified multiple times for different domains. | | `--txt-wildcard-replacement=""` | When using the TXT registry, a custom string that's used instead of an asterisk for TXT records corresponding to wildcard DNS records (optional) | | `--[no-]txt-encrypt-enabled` | When using the TXT registry, set if TXT records should be encrypted before stored (default: disabled) | | `--txt-encrypt-aes-key=""` | When using the TXT registry, set TXT record decryption and encryption 32 byte aes key (required when --txt-encrypt=true) | diff --git a/internal/idna/idna.go b/internal/idna/idna.go index 9290e8a51e..2b10b1882e 100644 --- a/internal/idna/idna.go +++ b/internal/idna/idna.go @@ -17,6 +17,9 @@ limitations under the License. package idna import ( + "strings" + + log "github.com/sirupsen/logrus" "golang.org/x/net/idna" ) @@ -27,3 +30,16 @@ var ( idna.StrictDomainName(false), ) ) + +// NormalizeDNSName converts a DNS name to a canonical form, so that we can use string equality +// it: removes space, get ASCII version of dnsName complient with Section 5 of RFC 5891, ensures there is a trailing dot +func NormalizeDNSName(dnsName string) string { + s, err := Profile.ToASCII(strings.TrimSpace(dnsName)) + if err != nil { + log.Warnf(`Got error while parsing DNSName %s: %v`, dnsName, err) + } + if !strings.HasSuffix(s, ".") { + s += "." + } + return s +} diff --git a/internal/idna/idna_test.go b/internal/idna/idna_test.go index f3ae93c446..5db2f498c8 100644 --- a/internal/idna/idna_test.go +++ b/internal/idna/idna_test.go @@ -57,3 +57,101 @@ func TestProfileWithDefault(t *testing.T) { }) } } + +func TestNormalizeDNSName(tt *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.", + }, + { + "_foo.com.", + "_foo.com.", + }, + { + "\u005Ffoo.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.", + }, + { + "點看.org.", + "xn--c1yn36f.org.", + }, + { + "nordic-ø.xn--kitty-點看pd34d.com", + "xn--nordic--w1a.xn--xn--kitty-pd34d-hn01b3542b.com.", + }, + { + "nordic-ø.kitty😸.com.", + "xn--nordic--w1a.xn--kitty-pd34d.com.", + }, + { + " nordic-ø.kitty😸.COM", + "xn--nordic--w1a.xn--kitty-pd34d.com.", + }, + { + "xn--nordic--w1a.kitty😸.com.", + "xn--nordic--w1a.xn--kitty-pd34d.com.", + }, + { + "*.example.com.", + "*.example.com.", + }, + { + "*.example.com", + "*.example.com.", + }, + } + for _, r := range records { + tt.Run(r.dnsName, func(t *testing.T) { + gotName := NormalizeDNSName(r.dnsName) + assert.Equal(t, r.expect, gotName) + }) + } +} diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index fccedde6d7..bafb6917b8 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -148,6 +148,7 @@ type Config struct { TXTOwnerOld string TXTPrefix string TXTSuffix string + TXTPrefixOverrides []string TXTEncryptEnabled bool TXTEncryptAESKey string `secure:"yes"` Interval time.Duration @@ -380,6 +381,7 @@ var defaultConfig = &Config{ TXTOwnerOld: "", TXTPrefix: "", TXTSuffix: "", + TXTPrefixOverrides: []string{}, TXTWildcardReplacement: "", UpdateEvents: false, WebhookProviderReadTimeout: 5 * time.Second, @@ -788,6 +790,7 @@ func bindFlags(b FlagBinder, cfg *Config) { b.StringVar("txt-owner-id", "When using the TXT or DynamoDB registry, a name that identifies this instance of ExternalDNS (default: default)", defaultConfig.TXTOwnerID, &cfg.TXTOwnerID) b.StringVar("txt-prefix", "When using the TXT registry, a custom string that's prefixed to each ownership DNS record (optional). Could contain record type template like '%{record_type}-prefix-'. Mutual exclusive with txt-suffix!", defaultConfig.TXTPrefix, &cfg.TXTPrefix) b.StringVar("txt-suffix", "When using the TXT registry, a custom string that's suffixed to the host portion of each ownership DNS record (optional). Could contain record type template like '-%{record_type}-suffix'. Mutual exclusive with txt-prefix!", defaultConfig.TXTSuffix, &cfg.TXTSuffix) + b.StringsVar("txt-prefix-override", "When using the TXT registry, specify domain-specific prefix overrides in the format 'domain=prefix' (optional). Useful for apex records that are difficult to create, e.g., 'example.com=%{record_type}.' for apex domains. Can be specified multiple times for different domains.", defaultConfig.TXTPrefixOverrides, &cfg.TXTPrefixOverrides) b.StringVar("txt-wildcard-replacement", "When using the TXT registry, a custom string that's used instead of an asterisk for TXT records corresponding to wildcard DNS records (optional)", defaultConfig.TXTWildcardReplacement, &cfg.TXTWildcardReplacement) b.BoolVar("txt-encrypt-enabled", "When using the TXT registry, set if TXT records should be encrypted before stored (default: disabled)", defaultConfig.TXTEncryptEnabled, &cfg.TXTEncryptEnabled) b.StringVar("txt-encrypt-aes-key", "When using the TXT registry, set TXT record decryption and encryption 32 byte aes key (required when --txt-encrypt=true)", defaultConfig.TXTEncryptAESKey, &cfg.TXTEncryptAESKey) diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index aa000366b0..8151a3cead 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -1494,3 +1494,41 @@ func TestBinderEnumValidationDifference(t *testing.T) { cfgC := runWithCobra(t, appArgs) assert.Equal(t, "bogus", cfgC.GoogleZoneVisibility) } + +func TestTXTPrefixOverridesFlagParsing(t *testing.T) { + tests := []struct { + name string + args []string + expected []string + }{ + { + name: "no overrides should be handled correctly", + args: []string{"--source", "fake", "--provider", "inmemory"}, + expected: nil, + }, + { + name: "single override flag should parse correctly", + args: []string{"--source", "fake", "--provider", "inmemory", "--txt-prefix-override", "example.com=custom"}, + expected: []string{"example.com=custom"}, + }, + { + name: "multiple override flags should parse correctly", + args: []string{"--source", "fake", "--provider", "inmemory", "--txt-prefix-override", "example.com=custom", "--txt-prefix-override", "test.com=another"}, + expected: []string{"example.com=custom", "test.com=another"}, + }, + { + name: "override with template should parse correctly", + args: []string{"--source", "fake", "--provider", "inmemory", "--txt-prefix-override", "api.example.com=%{record_type}-api"}, + expected: []string{"api.example.com=%{record_type}-api"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := NewConfig() + err := cfg.ParseFlags(test.args) + require.NoError(t, err) + assert.Equal(t, test.expected, cfg.TXTPrefixOverrides) + }) + } +} diff --git a/plan/plan.go b/plan/plan.go index 90cfb742e6..85852f54e8 100644 --- a/plan/plan.go +++ b/plan/plan.go @@ -19,7 +19,6 @@ package plan import ( "fmt" "slices" - "strings" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -142,7 +141,7 @@ func (t *planTable) addCandidate(e *endpoint.Endpoint) { func (t *planTable) newPlanKey(e *endpoint.Endpoint) planKey { key := planKey{ - dnsName: normalizeDNSName(e.DNSName), + dnsName: idna.NormalizeDNSName(e.DNSName), setIdentifier: e.SetIdentifier, } @@ -373,19 +372,6 @@ func filterRecordsForPlan(records []*endpoint.Endpoint, domainFilter endpoint.Ma return filtered } -// normalizeDNSName converts a DNS name to a canonical form, so that we can use string equality -// it: removes space, get ASCII version of dnsName complient with Section 5 of RFC 5891, ensures there is a trailing dot -func normalizeDNSName(dnsName string) string { - s, err := idna.Profile.ToASCII(strings.TrimSpace(dnsName)) - if err != nil { - log.Warnf(`Got error while parsing DNSName %s: %v`, dnsName, err) - } - if !strings.HasSuffix(s, ".") { - s += "." - } - return s -} - func IsManagedRecord(record string, managedRecords, excludeRecords []string) bool { if slices.Contains(excludeRecords, record) { return false diff --git a/plan/plan_test.go b/plan/plan_test.go index 68c7788204..b4b03ecab5 100644 --- a/plan/plan_test.go +++ b/plan/plan_test.go @@ -1054,104 +1054,6 @@ func validateEntries(t *testing.T, entries, expected []*endpoint.Endpoint) { } } -func TestNormalizeDNSName(tt *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.", - }, - { - "_foo.com.", - "_foo.com.", - }, - { - "\u005Ffoo.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.", - }, - { - "點看.org.", - "xn--c1yn36f.org.", - }, - { - "nordic-ø.xn--kitty-點看pd34d.com", - "xn--nordic--w1a.xn--xn--kitty-pd34d-hn01b3542b.com.", - }, - { - "nordic-ø.kitty😸.com.", - "xn--nordic--w1a.xn--kitty-pd34d.com.", - }, - { - " nordic-ø.kitty😸.COM", - "xn--nordic--w1a.xn--kitty-pd34d.com.", - }, - { - "xn--nordic--w1a.kitty😸.com.", - "xn--nordic--w1a.xn--kitty-pd34d.com.", - }, - { - "*.example.com.", - "*.example.com.", - }, - { - "*.example.com", - "*.example.com.", - }, - } - for _, r := range records { - tt.Run(r.dnsName, func(t *testing.T) { - gotName := normalizeDNSName(r.dnsName) - assert.Equal(t, r.expect, gotName) - }) - } -} - func TestShouldUpdateProviderSpecific(tt *testing.T) { for _, test := range []struct { name string diff --git a/registry/dynamodb.go b/registry/dynamodb.go index 5b83ceeb0f..bb17904989 100644 --- a/registry/dynamodb.go +++ b/registry/dynamodb.go @@ -94,7 +94,7 @@ func NewDynamoDBRegistry(provider provider.Provider, ownerID string, dynamodbAPI return nil, errors.New("txt-prefix and txt-suffix are mutually exclusive") } - mapper := newaffixNameMapper(txtPrefix, txtSuffix, txtWildcardReplacement) + mapper := newaffixNameMapper(txtPrefix, txtSuffix, txtWildcardReplacement, nil) return &DynamoDBRegistry{ provider: provider, diff --git a/registry/txt.go b/registry/txt.go index 7802367ce2..99aa3295d2 100644 --- a/registry/txt.go +++ b/registry/txt.go @@ -28,6 +28,7 @@ import ( log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/internal/idna" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) @@ -118,7 +119,7 @@ func NewTXTRegistry(provider provider.Provider, txtPrefix, txtSuffix, ownerID st cacheInterval time.Duration, txtWildcardReplacement string, managedRecordTypes, excludeRecordTypes []string, txtEncryptEnabled bool, txtEncryptAESKey []byte, - oldOwnerID string) (*TXTRegistry, error) { + oldOwnerID string, overrides map[string]string) (*TXTRegistry, error) { if ownerID == "" { return nil, errors.New("owner id cannot be empty") } @@ -140,7 +141,7 @@ func NewTXTRegistry(provider provider.Provider, txtPrefix, txtSuffix, ownerID st return nil, errors.New("txt-prefix and txt-suffix are mutual exclusive") } - mapper := newaffixNameMapper(txtPrefix, txtSuffix, txtWildcardReplacement) + mapper := newaffixNameMapper(txtPrefix, txtSuffix, txtWildcardReplacement, overrides) return &TXTRegistry{ provider: provider, @@ -398,23 +399,99 @@ func (im *TXTRegistry) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpo type nameMapper interface { toEndpointName(string) (endpointName string, recordType string) toTXTName(string, string) string - recordTypeInAffix() bool } type affixNameMapper struct { prefix string suffix string wildcardReplacement string + overrideNameMapper overridePrefixNameMapper +} + +// overridePrefixNameMapper provides fast bidirectional lookups for TXT record +// name overrides. +// +// It is used only when explicit domain -> prefix overrides are configured. +// All mappings are precomputed at initialization time so that conversions +// between endpoint names and TXT record names can be performed using simple +// O(1) map lookups during reconciliation. +// +// When nop is true, this mapper is disabled and all lookups fall back to the +// default name mapping logic. +type overridePrefixNameMapper struct { + nop bool + txtDNSNameToEndpoint map[string]endpointRecord + endpointToTXTDNSName map[endpointRecord]string +} + +func newOverridePrefixNameMapper(wildcardReplacement string, overrides map[string]string) overridePrefixNameMapper { + if len(overrides) == 0 { + return overridePrefixNameMapper{nop: true} + } + supportedTypes := getSupportedTypes() + txtDNSNameToEndpoint := make(map[string]endpointRecord, len(overrides)*len(supportedTypes)) + endpointToTXTDNSName := make(map[endpointRecord]string, len(overrides)*len(supportedTypes)) + + for domain, ovPrefix := range overrides { + ovPrefix = strings.ToLower(ovPrefix) + normalized := idna.NormalizeDNSName(domain) + for _, t := range supportedTypes { + txtName := toTXTName(ovPrefix, "", normalized, t, wildcardReplacement) + epName, epRecordType := toEndpointName(ovPrefix, "", txtName) + epRecord := newEndpointRecord(epName, epRecordType) + txtKey := idna.NormalizeDNSName(txtName) + txtDNSNameToEndpoint[txtKey] = epRecord + endpointToTXTDNSName[epRecord] = txtName + } + } + + return overridePrefixNameMapper{ + txtDNSNameToEndpoint: txtDNSNameToEndpoint, + endpointToTXTDNSName: endpointToTXTDNSName, + } +} + +type endpointRecord struct { + name string + recordType string +} + +func newEndpointRecord(name, recordType string) endpointRecord { + return endpointRecord{name: idna.NormalizeDNSName(name), recordType: recordType} +} + +func (o overridePrefixNameMapper) toEndpointName(txtDNSName string) (string, string, bool) { + if o.nop { + return "", "", false + } + if v, found := o.txtDNSNameToEndpoint[idna.NormalizeDNSName(txtDNSName)]; found { + return v.name, v.recordType, true + } + return "", "", false +} + +func (o overridePrefixNameMapper) toTXTName(endpointDNSName, recordType string) (string, bool) { + if o.nop { + return "", false + } + if v, found := o.endpointToTXTDNSName[newEndpointRecord(endpointDNSName, recordType)]; found { + return v, true + } + return "", false } var _ nameMapper = affixNameMapper{} -func newaffixNameMapper(prefix, suffix, wildcardReplacement string) affixNameMapper { - return affixNameMapper{prefix: strings.ToLower(prefix), suffix: strings.ToLower(suffix), wildcardReplacement: strings.ToLower(wildcardReplacement)} +func newaffixNameMapper(prefix, suffix, wildcardReplacement string, overrides map[string]string) affixNameMapper { + wr := strings.ToLower(wildcardReplacement) + return affixNameMapper{ + prefix: strings.ToLower(prefix), + suffix: strings.ToLower(suffix), + wildcardReplacement: wr, + overrideNameMapper: newOverridePrefixNameMapper(wr, overrides), + } } -// extractRecordTypeDefaultPosition extracts record type from the default position -// when not using '%{record_type}' in the prefix/suffix func extractRecordTypeDefaultPosition(name string) (string, string) { nameS := strings.Split(name, "-") for _, t := range getSupportedTypes() { @@ -425,70 +502,79 @@ func extractRecordTypeDefaultPosition(name string) (string, string) { return name, "" } -// dropAffixExtractType strips TXT record to find an endpoint name it manages +// dropPrefixExtractType strips TXT record to find an endpoint name it manages // it also returns the record type -func (pr affixNameMapper) dropAffixExtractType(name string) (string, string) { - prefix := pr.prefix - suffix := pr.suffix - - if pr.recordTypeInAffix() { +func dropPrefixExtractType(prefix, name string) (string, string) { + if strings.Contains(prefix, recordTemplate) { 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 after, found := strings.CutPrefix(name, iPrefix); found { + return after, t } + } - if pr.isSuffix() && strings.HasSuffix(name, iSuffix) { + // handle old TXT records + prefix = dropAffixTemplate(prefix) + } + + if after, found := strings.CutPrefix(name, prefix); found { + return extractRecordTypeDefaultPosition(after) + } + return "", "" +} + +// dropSuffixExtractType strips TXT record to find an endpoint name it manages +// it also returns the record type +func dropSuffixExtractType(suffix, name string) (string, string) { + if strings.Contains(suffix, recordTemplate) { + for _, t := range getSupportedTypes() { + tLower := strings.ToLower(t) + iSuffix := strings.ReplaceAll(suffix, recordTemplate, tLower) + + if strings.HasSuffix(name, iSuffix) { return strings.TrimSuffix(name, iSuffix), t } } // handle old TXT records - prefix = pr.dropAffixTemplate(prefix) - suffix = pr.dropAffixTemplate(suffix) + suffix = dropAffixTemplate(suffix) } - if pr.isPrefix() && strings.HasPrefix(name, prefix) { - return extractRecordTypeDefaultPosition(strings.TrimPrefix(name, prefix)) - } - - if pr.isSuffix() && strings.HasSuffix(name, suffix) { + if strings.HasSuffix(name, suffix) { return extractRecordTypeDefaultPosition(strings.TrimSuffix(name, suffix)) } return "", "" } -func (pr affixNameMapper) dropAffixTemplate(name string) string { +func dropAffixTemplate(name string) string { return strings.ReplaceAll(name, recordTemplate, "") } -func (pr affixNameMapper) isPrefix() bool { - return len(pr.suffix) == 0 -} +func (pr affixNameMapper) toEndpointName(txtDNSName string) (string, string) { + if endpointName, recordType, override := pr.overrideNameMapper.toEndpointName(txtDNSName); override { + return endpointName, recordType + } -func (pr affixNameMapper) isSuffix() bool { - return len(pr.prefix) == 0 && len(pr.suffix) > 0 + return toEndpointName(pr.prefix, pr.suffix, txtDNSName) } -func (pr affixNameMapper) toEndpointName(txtDNSName string) (string, string) { +func toEndpointName(prefix, suffix, txtDNSName string) (string, string) { lowerDNSName := strings.ToLower(txtDNSName) - // drop prefix - if pr.isPrefix() { - return pr.dropAffixExtractType(lowerDNSName) + if len(suffix) == 0 { + return dropPrefixExtractType(prefix, lowerDNSName) } // drop suffix - if pr.isSuffix() { - dc := strings.Count(pr.suffix, ".") + if len(prefix) == 0 { + dc := strings.Count(suffix, ".") DNSName := strings.SplitN(lowerDNSName, ".", 2+dc) domainWithSuffix := strings.Join(DNSName[:1+dc], ".") - r, rType := pr.dropAffixExtractType(domainWithSuffix) + r, rType := dropSuffixExtractType(suffix, domainWithSuffix) if !strings.Contains(lowerDNSName, ".") { return r, rType } @@ -497,17 +583,17 @@ func (pr affixNameMapper) toEndpointName(txtDNSName string) (string, string) { return "", "" } -func (pr affixNameMapper) recordTypeInAffix() bool { - if strings.Contains(pr.prefix, recordTemplate) { +func recordTypeInAffix(prefix, suffix string) bool { + if strings.Contains(prefix, recordTemplate) { return true } - if strings.Contains(pr.suffix, recordTemplate) { + if strings.Contains(suffix, recordTemplate) { return true } return false } -func (pr affixNameMapper) normalizeAffixTemplate(afix, recordType string) string { +func normalizeAffixTemplate(afix, recordType string) string { if strings.Contains(afix, recordTemplate) { return strings.ReplaceAll(afix, recordTemplate, recordType) } @@ -515,19 +601,27 @@ func (pr affixNameMapper) normalizeAffixTemplate(afix, recordType string) string } func (pr affixNameMapper) toTXTName(endpointDNSName, recordType string) string { - DNSName := strings.SplitN(endpointDNSName, ".", 2) + if txtDNSName, override := pr.overrideNameMapper.toTXTName(endpointDNSName, recordType); override { + return txtDNSName + } + return toTXTName(pr.prefix, pr.suffix, endpointDNSName, recordType, pr.wildcardReplacement) +} + +func toTXTName(prefix, suffix, endpointDNSName, recordType, wildcardReplacement string) string { recordType = strings.ToLower(recordType) + DNSName := strings.SplitN(endpointDNSName, ".", 2) recordT := recordType + "-" - prefix := pr.normalizeAffixTemplate(pr.prefix, recordType) - suffix := pr.normalizeAffixTemplate(pr.suffix, recordType) + recordTypeInfAffix := recordTypeInAffix(prefix, suffix) + prefix = normalizeAffixTemplate(prefix, recordType) + suffix = normalizeAffixTemplate(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 wildcardReplacement != "" && DNSName[0] == "*" { + DNSName[0] = wildcardReplacement } - if !pr.recordTypeInAffix() { + if !recordTypeInfAffix { DNSName[0] = recordT + DNSName[0] } diff --git a/registry/txt_encryption_test.go b/registry/txt_encryption_test.go index 65cee6248d..d5674752ba 100644 --- a/registry/txt_encryption_test.go +++ b/registry/txt_encryption_test.go @@ -61,7 +61,7 @@ func TestNewTXTRegistryEncryptionConfig(t *testing.T) { }, } for _, test := range tests { - actual, err := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "", []string{}, []string{}, test.encEnabled, test.aesKeyRaw, "") + actual, err := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "", []string{}, []string{}, test.encEnabled, test.aesKeyRaw, "", nil) if test.errorExpected { require.Error(t, err) } else { @@ -107,7 +107,7 @@ func TestGenerateTXTGenerateTextRecordEncryptionWihDecryption(t *testing.T) { for _, k := range withEncryptionKeys { t.Run(fmt.Sprintf("key '%s' with decrypted result '%s'", k, test.decrypted), func(t *testing.T) { key := []byte(k) - r, err := NewTXTRegistry(p, "", "", "owner", time.Minute, "", []string{}, []string{}, true, key, "") + r, err := NewTXTRegistry(p, "", "", "owner", time.Minute, "", []string{}, []string{}, true, key, "", nil) assert.NoError(t, err, "Error creating TXT registry") txtRecords := r.generateTXTRecord(test.record) assert.Len(t, txtRecords, len(test.record.Targets)) @@ -144,7 +144,7 @@ func TestApplyRecordsWithEncryption(t *testing.T) { key := []byte("ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY=") - r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, key, "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, key, "", nil) _ = r.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ @@ -202,7 +202,7 @@ func TestApplyRecordsWithEncryptionKeyChanged(t *testing.T) { } for _, key := range withEncryptionKeys { - r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, []byte(key), "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, []byte(key), "", nil) _ = r.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner"), @@ -232,7 +232,7 @@ func TestApplyRecordsOnEncryptionKeyChangeWithKeyIdLabel(t *testing.T) { } for i, key := range withEncryptionKeys { - r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, []byte(key), "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, []byte(key), "", nil) keyId := fmt.Sprintf("key-id-%d", i) changes := []*endpoint.Endpoint{ newEndpointWithOwnerAndOwnedRecordWithKeyIDLabel("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner", "", keyId), diff --git a/registry/txt_test.go b/registry/txt_test.go index b7bb36732b..9c9a014fdc 100644 --- a/registry/txt_test.go +++ b/registry/txt_test.go @@ -49,20 +49,20 @@ func TestTXTRegistry(t *testing.T) { func testTXTRegistryNew(t *testing.T) { p := inmemory.NewInMemoryProvider() - _, err := NewTXTRegistry(p, "txt", "", "", time.Hour, "", []string{}, []string{}, false, nil, "") + _, err := NewTXTRegistry(p, "txt", "", "", time.Hour, "", []string{}, []string{}, false, nil, "", nil) require.Error(t, err) - _, err = NewTXTRegistry(p, "", "txt", "", time.Hour, "", []string{}, []string{}, false, nil, "") + _, err = NewTXTRegistry(p, "", "txt", "", time.Hour, "", []string{}, []string{}, false, nil, "", nil) require.Error(t, err) - r, err := NewTXTRegistry(p, "txt", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") + r, err := NewTXTRegistry(p, "txt", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) require.NoError(t, err) assert.Equal(t, p, r.provider) - r, err = NewTXTRegistry(p, "", "txt", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") + r, err = NewTXTRegistry(p, "", "txt", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) require.NoError(t, err) - _, err = NewTXTRegistry(p, "txt", "txt", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") + _, err = NewTXTRegistry(p, "txt", "txt", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) require.Error(t, err) _, ok := r.mapper.(affixNameMapper) @@ -71,16 +71,16 @@ func testTXTRegistryNew(t *testing.T) { 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, "") + _, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) require.NoError(t, err) - _, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, aesKey, "") + _, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, aesKey, "", nil) require.NoError(t, err) - _, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, nil, "") + _, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, nil, "", nil) require.Error(t, err) - r, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, aesKey, "") + r, err = NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, aesKey, "", nil) require.NoError(t, err) _, ok = r.mapper.(affixNameMapper) @@ -228,13 +228,13 @@ func testTXTRegistryRecordsPrefixed(t *testing.T) { }, } - r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "", 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, "") + r, _ = NewTXTRegistry(p, "TxT.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "", nil) records, _ = r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) @@ -363,13 +363,13 @@ func testTXTRegistryRecordsSuffixed(t *testing.T) { }, } - r, _ := NewTXTRegistry(p, "", "-txt", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "", "-txt", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", 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, "") + r, _ = NewTXTRegistry(p, "", "-TxT", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) records, _ = r.Records(ctx) assert.True(t, testutils.SameEndpointLabels(records, expectedRecords)) @@ -490,7 +490,7 @@ func testTXTRegistryRecordsNoPrefix(t *testing.T) { }, } - r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) records, _ := r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) @@ -527,12 +527,12 @@ func testTXTRegistryRecordsPrefixedTemplated(t *testing.T) { }, } - r, _ := NewTXTRegistry(p, "txt-%{record_type}.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "txt-%{record_type}.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "", 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, "") + r, _ = NewTXTRegistry(p, "TxT-%{record_type}.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "", nil) records, _ = r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) @@ -569,12 +569,12 @@ func testTXTRegistryRecordsSuffixedTemplated(t *testing.T) { }, } - r, _ := NewTXTRegistry(p, "", "txt%{record_type}", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "", "txt%{record_type}", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "", 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, "") + r, _ = NewTXTRegistry(p, "", "TxT%{record_type}", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "", nil) records, _ = r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) @@ -617,7 +617,7 @@ func testTXTRegistryApplyChangesWithPrefix(t *testing.T) { 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, "") + r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) changes := &plan.Changes{ Create: []*endpoint.Endpoint{ @@ -698,7 +698,7 @@ func testTXTRegistryApplyChangesWithTemplatedPrefix(t *testing.T) { p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{}, }) - r, _ := NewTXTRegistry(p, "prefix%{record_type}.", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "prefix%{record_type}.", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", 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"), @@ -741,7 +741,7 @@ func testTXTRegistryApplyChangesWithTemplatedSuffix(t *testing.T) { 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, "") + r, _ := NewTXTRegistry(p, "", "-%{record_type}suffix", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", 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"), @@ -806,7 +806,7 @@ func testTXTRegistryApplyChangesWithSuffix(t *testing.T) { 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, "") + r, _ := NewTXTRegistry(p, "", "-txt", "owner", time.Hour, "wildcard", []string{}, []string{}, false, nil, "", nil) changes := &plan.Changes{ Create: []*endpoint.Endpoint{ @@ -900,7 +900,7 @@ func testTXTRegistryApplyChangesNoPrefix(t *testing.T) { 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, "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) changes := &plan.Changes{ Create: []*endpoint.Endpoint{ @@ -1058,7 +1058,7 @@ func testTXTRegistryMissingRecordsNoPrefix(t *testing.T) { }, } - r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "wc", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "wc", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS}, []string{}, false, nil, "", nil) records, _ := r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) @@ -1168,7 +1168,7 @@ func testTXTRegistryMissingRecordsWithPrefix(t *testing.T) { }, } - r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "wc", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS, endpoint.RecordTypeTXT}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "wc", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS, endpoint.RecordTypeTXT}, []string{}, false, nil, "", nil) records, _ := r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) @@ -1242,7 +1242,6 @@ func TestCacheMethods(t *testing.T) { } func TestDropPrefix(t *testing.T) { - mapper := newaffixNameMapper("foo-%{record_type}-", "", "") expectedOutput := "test.example.com" tests := []string{ @@ -1253,14 +1252,13 @@ func TestDropPrefix(t *testing.T) { for _, tc := range tests { t.Run(tc, func(t *testing.T) { - actualOutput, _ := mapper.dropAffixExtractType(tc) + actualOutput, _ := dropPrefixExtractType("foo-%{record_type}-", tc) assert.Equal(t, expectedOutput, actualOutput) }) } } func TestDropSuffix(t *testing.T) { - mapper := newaffixNameMapper("", "-%{record_type}-foo", "") expectedOutput := "test.example.com" tests := []string{ @@ -1271,7 +1269,7 @@ func TestDropSuffix(t *testing.T) { for _, tc := range tests { t.Run(tc, func(t *testing.T) { r := strings.SplitN(tc, ".", 2) - rClean, _ := mapper.dropAffixExtractType(r[0]) + rClean, _ := dropSuffixExtractType("-%{record_type}-foo", r[0]) actualOutput := rClean + "." + r[1] assert.Equal(t, expectedOutput, actualOutput) }) @@ -1325,113 +1323,149 @@ func TestToEndpointNameNewTXT(t *testing.T) { }{ { name: "prefix", - mapper: newaffixNameMapper("foo", "", ""), + mapper: newaffixNameMapper("foo", "", "", nil), domain: "example.com", recordType: "A", txtDomain: "fooa-example.com", }, + { + name: "prefix and domain with trailing dot", + mapper: newaffixNameMapper("foo", "", "", nil), + domain: "example.com.", + recordType: "A", + txtDomain: "fooa-example.com.", + }, { name: "suffix", - mapper: newaffixNameMapper("", "foo", ""), + mapper: newaffixNameMapper("", "foo", "", nil), domain: "example", recordType: "AAAA", txtDomain: "aaaa-examplefoo", }, { name: "suffix", - mapper: newaffixNameMapper("", "foo", ""), + mapper: newaffixNameMapper("", "foo", "", nil), domain: "example.com", recordType: "AAAA", txtDomain: "aaaa-examplefoo.com", }, { name: "prefix with dash", - mapper: newaffixNameMapper("foo-", "", ""), + mapper: newaffixNameMapper("foo-", "", "", nil), domain: "example.com", recordType: "A", txtDomain: "foo-a-example.com", }, { name: "suffix with dash", - mapper: newaffixNameMapper("", "-foo", ""), + mapper: newaffixNameMapper("", "-foo", "", nil), domain: "example.com", recordType: "CNAME", txtDomain: "cname-example-foo.com", }, { name: "prefix with dot", - mapper: newaffixNameMapper("foo.", "", ""), + mapper: newaffixNameMapper("foo.", "", "", nil), domain: "example.com", recordType: "CNAME", txtDomain: "foo.cname-example.com", }, { name: "suffix with dot", - mapper: newaffixNameMapper("", ".foo", ""), + mapper: newaffixNameMapper("", ".foo", "", nil), domain: "example.com", recordType: "CNAME", txtDomain: "cname-example.foo.com", }, { name: "prefix with multiple dots", - mapper: newaffixNameMapper("foo.bar.", "", ""), + mapper: newaffixNameMapper("foo.bar.", "", "", nil), domain: "example.com", recordType: "CNAME", txtDomain: "foo.bar.cname-example.com", }, { name: "suffix with multiple dots", - mapper: newaffixNameMapper("", ".foo.bar.test", ""), + mapper: newaffixNameMapper("", ".foo.bar.test", "", nil), domain: "example.com", recordType: "CNAME", txtDomain: "cname-example.foo.bar.test.com", }, { name: "templated prefix", - mapper: newaffixNameMapper("%{record_type}-foo", "", ""), + mapper: newaffixNameMapper("%{record_type}-foo", "", "", nil), domain: "example.com", recordType: "A", txtDomain: "a-fooexample.com", }, { name: "templated suffix", - mapper: newaffixNameMapper("", "foo-%{record_type}", ""), + mapper: newaffixNameMapper("", "foo-%{record_type}", "", nil), domain: "example.com", recordType: "A", txtDomain: "examplefoo-a.com", }, { name: "templated prefix with dot", - mapper: newaffixNameMapper("%{record_type}foo.", "", ""), + mapper: newaffixNameMapper("%{record_type}foo.", "", "", nil), domain: "example.com", recordType: "CNAME", txtDomain: "cnamefoo.example.com", }, { name: "templated suffix with dot", - mapper: newaffixNameMapper("", ".foo%{record_type}", ""), + mapper: newaffixNameMapper("", ".foo%{record_type}", "", nil), domain: "example.com", recordType: "A", txtDomain: "example.fooa.com", }, { name: "templated prefix with multiple dots", - mapper: newaffixNameMapper("bar.%{record_type}.foo.", "", ""), + mapper: newaffixNameMapper("bar.%{record_type}.foo.", "", "", nil), 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", + name: "override for specific A record with template prefix", + mapper: newaffixNameMapper("foo", "", "", map[string]string{"apex.com": "%{record_type}-"}), + domain: "apex.com.", + recordType: "A", + txtDomain: "a-apex.com.", + }, + { + name: "override for specific AAAA record with template prefix", + mapper: newaffixNameMapper("foo", "", "", map[string]string{"apex.com": "%{record_type}-"}), + domain: "apex.com.", + recordType: "AAAA", + txtDomain: "aaaa-apex.com.", + }, + { + name: "override for multiple domains selects correct domain-specific prefix", + mapper: newaffixNameMapper("foo", "", "", map[string]string{"apex.com": "%{record_type}-foo.", "example.com": "bar"}), + domain: "example.com.", + recordType: "A", + txtDomain: "bara-example.com.", + }, + { + name: "override with empty prefix uses only record type", + mapper: newaffixNameMapper("foo", "", "", map[string]string{"apex.com": "%{record_type}-foo.", "example.com": ""}), + domain: "example.com.", + recordType: "A", + txtDomain: "a-example.com.", + }, + { + name: "override key matches with trailing dot and mixed case", + mapper: newaffixNameMapper("foo", "", "", map[string]string{"APeX.com.": "%{record_type}-"}), + domain: "apex.com.", recordType: "A", - txtDomain: "example.fooa.bar.com", + txtDomain: "a-apex.com.", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + t.Parallel() txtDomain := tc.mapper.toTXTName(tc.domain, tc.recordType) assert.Equal(t, tc.txtDomain, txtDomain) @@ -1441,6 +1475,174 @@ func TestToEndpointNameNewTXT(t *testing.T) { } } +func TestOverridePrefixNameMapper_toTXTName(t *testing.T) { + tests := []struct { + name string + overrides map[string]string + wildcardReplacement string + endpointName string + recordType string + expected string + found bool + }{ + { + name: "should return not found when overrides is empty", + overrides: map[string]string{}, + wildcardReplacement: "", + endpointName: "apex.com.", + recordType: "A", + expected: "", + found: false, + }, + { + name: "should return overridden TXT name for template prefix", + overrides: map[string]string{"apex.com": "%{record_type}-foo."}, + wildcardReplacement: "", + endpointName: "apex.com.", + recordType: "A", + expected: "a-foo.apex.com.", + found: true, + }, + { + name: "should return overridden TXT name for AAAA record with template prefix", + overrides: map[string]string{"apex.com": "%{record_type}-"}, + wildcardReplacement: "", + endpointName: "apex.com.", + recordType: "AAAA", + expected: "aaaa-apex.com.", + found: true, + }, + { + name: "should return overridden TXT name when multiple overrides exist", + overrides: map[string]string{"apex.com": "%{record_type}-foo.", "example.com": "bar"}, + wildcardReplacement: "", + endpointName: "example.com.", + recordType: "A", + expected: "bara-example.com.", + found: true, + }, + { + name: "should return overridden TXT name when override prefix is empty", + overrides: map[string]string{"apex.com": "%{record_type}-foo.", "example.com": ""}, + wildcardReplacement: "", + endpointName: "example.com.", + recordType: "A", + expected: "a-example.com.", + found: true, + }, + { + name: "should return not found for non-overridden endpoint", + overrides: map[string]string{"apex.com": "%{record_type}-foo."}, + wildcardReplacement: "", + endpointName: "not-apex.com.", + recordType: "A", + expected: "", + found: false, + }, + { + name: "should return overridden TXT name for wildcard endpoint after wildcard replacement", + overrides: map[string]string{"*.apex.com": "%{record_type}-"}, + wildcardReplacement: "wild", + endpointName: "wild.apex.com.", // "*" is replaced with "wild" by provider + recordType: "A", + expected: "a-wild.apex.com.", + found: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mapper := newOverridePrefixNameMapper(tt.wildcardReplacement, tt.overrides) + actual, found := mapper.toTXTName(tt.endpointName, tt.recordType) + + assert.Equal(t, tt.expected, actual) + assert.Equal(t, tt.found, found) + }) + } +} + +func TestOverridePrefixNameMapper_toEndpointName(t *testing.T) { + tests := []struct { + name string + overrides map[string]string + wildcardReplacement string + txtDNSName string + expectedEndpointName string + expectedRecordType string + found bool + }{ + { + name: "should return not found when overrides is empty", + overrides: map[string]string{}, + wildcardReplacement: "", + txtDNSName: "a-foo.apex.com.", + expectedEndpointName: "", + expectedRecordType: "", + found: false, + }, + { + name: "should return endpoint name and record type for overridden TXT name with template prefix", + overrides: map[string]string{"apex.com": "%{record_type}-foo."}, + wildcardReplacement: "", + txtDNSName: "a-foo.apex.com.", + expectedEndpointName: "apex.com.", + expectedRecordType: "A", + found: true, + }, + { + name: "should return endpoint name and record type for overridden AAAA TXT name with template prefix", + overrides: map[string]string{"apex.com": "%{record_type}-"}, + wildcardReplacement: "", + txtDNSName: "aaaa-apex.com.", + expectedEndpointName: "apex.com.", + expectedRecordType: "AAAA", + found: true, + }, + { + name: "should return endpoint name and record type for overridden TXT name regardless of casing and trailing dot", + overrides: map[string]string{"apex.com": "%{record_type}-foo."}, + wildcardReplacement: "", + txtDNSName: "A-FOO.APEX.COM", + expectedEndpointName: "apex.com.", + expectedRecordType: "A", + found: true, + }, + { + name: "should return endpoint name and record type for wildcard TXT name after wildcard replacement", + overrides: map[string]string{"*.apex.com": "%{record_type}-"}, + wildcardReplacement: "wild", + txtDNSName: "a-wild.apex.com.", + expectedEndpointName: "wild.apex.com.", + expectedRecordType: "A", + found: true, + }, + { + name: "should return not found for TXT name that does not match any override mapping", + overrides: map[string]string{"apex.com": "%{record_type}-foo."}, + wildcardReplacement: "", + txtDNSName: "a-foo.example.com.", + expectedEndpointName: "", + expectedRecordType: "", + found: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mapper := newOverridePrefixNameMapper(tt.wildcardReplacement, tt.overrides) + actualEndpointName, actualRecordType, found := mapper.toEndpointName(tt.txtDNSName) + + assert.Equal(t, tt.expectedEndpointName, actualEndpointName) + assert.Equal(t, tt.expectedRecordType, actualRecordType) + assert.Equal(t, tt.found, found) + }) + } +} + func TestNewTXTScheme(t *testing.T) { p := inmemory.NewInMemoryProvider() p.CreateZone(testZone) @@ -1463,7 +1665,7 @@ func TestNewTXTScheme(t *testing.T) { 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, "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) changes := &plan.Changes{ Create: []*endpoint.Endpoint{ @@ -1528,7 +1730,7 @@ func TestGenerateTXT(t *testing.T) { } p := inmemory.NewInMemoryProvider() p.CreateZone(testZone) - r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) gotTXT := r.generateTXTRecord(record) assert.Equal(t, expectedTXT, gotTXT) } @@ -1547,7 +1749,7 @@ func TestGenerateTXTWithMigration(t *testing.T) { } p := inmemory.NewInMemoryProvider() p.CreateZone(testZone) - r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) gotTXTBeforeMigration := r.generateTXTRecord(record) assert.Equal(t, expectedTXTBeforeMigration, gotTXTBeforeMigration) @@ -1562,7 +1764,7 @@ func TestGenerateTXTWithMigration(t *testing.T) { }, } - rMigrated, _ := NewTXTRegistry(p, "", "", "foobar", time.Hour, "", []string{}, []string{}, false, nil, "owner") + rMigrated, _ := NewTXTRegistry(p, "", "", "foobar", time.Hour, "", []string{}, []string{}, false, nil, "owner", nil) gotTXTAfterMigration := rMigrated.generateTXTRecord(record) assert.Equal(t, expectedTXTAfterMigration, gotTXTAfterMigration) @@ -1582,7 +1784,7 @@ func TestGenerateTXTForAAAA(t *testing.T) { } p := inmemory.NewInMemoryProvider() p.CreateZone(testZone) - r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) gotTXT := r.generateTXTRecord(record) assert.Equal(t, expectedTXT, gotTXT) } @@ -1599,7 +1801,7 @@ func TestFailGenerateTXT(t *testing.T) { expectedTXT := []*endpoint.Endpoint{} p := inmemory.NewInMemoryProvider() p.CreateZone(testZone) - r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) gotTXT := r.generateTXTRecord(cnameRecord) assert.Equal(t, expectedTXT, gotTXT) } @@ -1617,7 +1819,7 @@ func TestTXTRegistryApplyChangesEncrypt(t *testing.T) { }, }) - r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "", []string{}, []string{}, true, []byte("12345678901234567890123456789012"), "") + r, _ := NewTXTRegistry(p, "txt.", "", "owner", time.Hour, "", []string{}, []string{}, true, []byte("12345678901234567890123456789012"), "", nil) records, _ := r.Records(ctx) changes := &plan.Changes{ Delete: records, @@ -1663,7 +1865,7 @@ func TestMultiClusterDifferentRecordTypeOwnership(t *testing.T) { }, }) - r, _ := NewTXTRegistry(p, "_owner.", "", "bar", time.Hour, "", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "_owner.", "", "bar", time.Hour, "", []string{}, []string{}, false, nil, "", nil) records, _ := r.Records(ctx) // new cluster has same ingress host as other cluster and uses CNAME ingress address @@ -1748,7 +1950,7 @@ func TestGenerateTXTRecordWithNewFormatOnly(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) records := r.generateTXTRecord(tc.endpoint) assert.Len(t, records, tc.expectedRecords, tc.description) @@ -1777,7 +1979,7 @@ func TestApplyChangesWithNewFormatOnly(t *testing.T) { p.CreateZone(testZone) ctx := context.Background() - r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) changes := &plan.Changes{ Create: []*endpoint.Endpoint{ @@ -1825,7 +2027,7 @@ func TestTXTRegistryRecordsWithEmptyTargets(t *testing.T) { }, }) - r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "", nil) hook := testutils.LogsUnderTestWithLogLevel(log.ErrorLevel, t) records, err := r.Records(ctx) require.NoError(t, err) @@ -2029,7 +2231,7 @@ func TestTXTRegistryRecreatesMissingRecords(t *testing.T) { // When: Apply changes to recreate missing A records managedRecords := []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME, endpoint.RecordTypeAAAA, endpoint.RecordTypeTXT} - registry, err := NewTXTRegistry(p, "", "", ownerId, time.Hour, "", managedRecords, nil, false, nil, "") + registry, err := NewTXTRegistry(p, "", "", ownerId, time.Hour, "", managedRecords, nil, false, nil, "", nil) assert.NoError(t, err) expectedRecords := append(existing, expectedCreate...) // nolint:gocritic @@ -2069,7 +2271,7 @@ func TestTXTRecordMigration(t *testing.T) { p := inmemory.NewInMemoryProvider() p.CreateZone(testZone) - r, _ := NewTXTRegistry(p, "%{record_type}-", "", "foo", time.Hour, "", []string{}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "%{record_type}-", "", "foo", time.Hour, "", []string{}, []string{}, false, nil, "", nil) r.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ @@ -2092,7 +2294,7 @@ func TestTXTRecordMigration(t *testing.T) { assert.Equal(t, expectedTXTRecords[0].Targets, newTXTRecord[0].Targets) - r, _ = NewTXTRegistry(p, "%{record_type}-", "", "foobar", time.Hour, "", []string{}, []string{}, false, nil, "foo") + r, _ = NewTXTRegistry(p, "%{record_type}-", "", "foobar", time.Hour, "", []string{}, []string{}, false, nil, "foo", nil) updatedRecords, _ := r.Records(ctx) @@ -2120,7 +2322,7 @@ func TestRecreateRecordAfterDeletion(t *testing.T) { p := inmemory.NewInMemoryProvider() p.CreateZone(testZone) - r, _ := NewTXTRegistry(p, "%{record_type}-", "", "foo", 0, "", []string{endpoint.RecordTypeA}, []string{}, false, nil, "") + r, _ := NewTXTRegistry(p, "%{record_type}-", "", "foo", 0, "", []string{endpoint.RecordTypeA}, []string{}, false, nil, "", nil) createdRecords := newEndpointWithOwnerAndLabels("bar.test-zone.example.org", "1.2.3.4", endpoint.RecordTypeA, ownerID, nil) txtRecord := r.generateTXTRecord(createdRecords)