dnscontrol/pkg/diff2/externaldns_test.go
tridion f1b30a1a04
feat: Add IGNORE_EXTERNAL_DNS() for Kubernetes external-dns coexistence (#3869)s
## Summary

This PR adds a new domain modifier `IGNORE_EXTERNAL_DNS()` that
automatically detects and ignores DNS records managed by Kubernetes
[external-dns](https://github.com/kubernetes-sigs/external-dns)
controller.

**Related Issue:** This addresses the feature request discussed in
StackExchange/dnscontrol#935 (Idea: Ownership system), where
@tlimoncelli indicated openness to accepting a PR for this
functionality.

## Problem

When running DNSControl alongside Kubernetes external-dns, users face a
challenge:

- **external-dns** dynamically creates DNS records based on Kubernetes
Ingress/Service resources
- Users cannot use `IGNORE()` because they cannot predict which record
names external-dns will create
- Using `NO_PURGE()` is too broad - it prevents DNSControl from cleaning
up any orphaned records

The fundamental issue is that `IGNORE()` requires static patterns known
at config-time, but external-dns creates records dynamically at runtime.

## Solution

`IGNORE_EXTERNAL_DNS()` solves this by detecting external-dns managed
records at runtime:

```javascript
D("example.com", REG_CHANGEME, DnsProvider(DSP_MY_PROVIDER),
    IGNORE_EXTERNAL_DNS(),  // Automatically ignore external-dns managed records
    A("@", "1.2.3.4"),
    CNAME("www", "@")
);
```

### How It Works

external-dns uses a TXT record registry to track ownership. For each
managed record, it creates a TXT record like:

- `a-myapp.example.com` → TXT containing
`heritage=external-dns,external-dns/owner=...`
- `cname-api.example.com` → TXT containing
`heritage=external-dns,external-dns/owner=...`

This PR:
1. Scans existing TXT records for the `heritage=external-dns` marker
2. Parses the TXT record name prefix (e.g., `a-`, `cname-`) to determine
the managed record type
3. Automatically adds those records to the ignore list during diff
operations

## Changes

| File | Purpose |
|------|---------|
| `models/domain.go` | Add `IgnoreExternalDNS` field to DomainConfig |
| `pkg/js/helpers.js` | Add `IGNORE_EXTERNAL_DNS()` JavaScript helper |
| `pkg/diff2/externaldns.go` | Core detection logic for external-dns TXT
records |
| `pkg/diff2/externaldns_test.go` | Unit tests for detection logic |
| `pkg/diff2/handsoff.go` | Integrate external-dns detection into
handsoff() |
| `pkg/diff2/diff2.go` | Pass IgnoreExternalDNS flag to handsoff() |
| `commands/types/dnscontrol.d.ts` | TypeScript definitions for IDE
support |
| `documentation/.../IGNORE_EXTERNAL_DNS.md` | User documentation |

## Design Philosophy

This follows DNSControl's pattern of convenience builders (like
`M365_BUILDER`, `SPF_BUILDER`, `DKIM_BUILDER`) that make complex
operations simple. Just as those builders abstract away implementation
details, `IGNORE_EXTERNAL_DNS()` abstracts away the complexity of
detecting external-dns managed records.

## Testing

All unit tests pass:
```
go test ./pkg/diff2/... -v  # Tests detection logic
go test ./pkg/js/...        # Tests JS helpers
go build ./...              # Builds successfully
```

## Caveats Documented

- Only supports TXT registry (the default for external-dns)
- Requires external-dns to use default naming conventions
- May need updates if external-dns changes its registry format

---------

Co-authored-by: Tom Limoncelli <6293917+tlimoncelli@users.noreply.github.com>
2025-12-03 08:56:55 -05:00

528 lines
16 KiB
Go

package diff2
import (
"testing"
"github.com/StackExchange/dnscontrol/v4/models"
)
func makeTestRecord(name, rtype, target, domain string) *models.RecordConfig {
rc := &models.RecordConfig{
Type: rtype,
}
rc.SetLabel(name, domain)
if rtype == "TXT" {
rc.SetTargetTXT(target)
} else {
rc.SetTarget(target)
}
return rc
}
func TestIsExternalDNSTxtRecord(t *testing.T) {
domain := "example.com"
tests := []struct {
name string
record *models.RecordConfig
wantIsExtDNS bool
wantLabel string
wantRecordType string
}{
{
name: "external-dns A record TXT",
record: makeTestRecord("a-myapp", "TXT", "heritage=external-dns,external-dns/owner=my-cluster,external-dns/resource=ingress/default/myapp", domain),
wantIsExtDNS: true,
wantLabel: "myapp",
wantRecordType: "A",
},
{
name: "external-dns AAAA record TXT",
record: makeTestRecord("aaaa-myapp", "TXT", "heritage=external-dns,external-dns/owner=my-cluster", domain),
wantIsExtDNS: true,
wantLabel: "myapp",
wantRecordType: "AAAA",
},
{
name: "external-dns CNAME record TXT",
record: makeTestRecord("cname-www", "TXT", "heritage=external-dns,external-dns/owner=default", domain),
wantIsExtDNS: true,
wantLabel: "www",
wantRecordType: "CNAME",
},
{
name: "external-dns apex A record TXT",
record: makeTestRecord("a-", "TXT", "heritage=external-dns,external-dns/owner=k8s", domain),
wantIsExtDNS: true,
wantLabel: "@",
wantRecordType: "A",
},
{
name: "non-external-dns TXT record",
record: makeTestRecord("myapp", "TXT", "some random txt content", domain),
wantIsExtDNS: false,
},
{
name: "A record (not TXT)",
record: makeTestRecord("myapp", "A", "1.2.3.4", domain),
wantIsExtDNS: false,
},
{
name: "SPF record",
record: makeTestRecord("@", "TXT", "v=spf1 include:_spf.google.com ~all", domain),
wantIsExtDNS: false,
},
{
name: "DKIM record",
record: makeTestRecord("selector._domainkey", "TXT", "v=DKIM1; k=rsa; p=MIGfMA0G...", domain),
wantIsExtDNS: false,
},
{
name: "external-dns with quoted heritage",
record: makeTestRecord("a-test", "TXT", "\"heritage=external-dns,external-dns/owner=test\"", domain),
wantIsExtDNS: true,
wantLabel: "test",
wantRecordType: "A",
},
{
name: "external-dns legacy format (no prefix)",
record: makeTestRecord("legacy-app", "TXT", "heritage=external-dns,external-dns/owner=old-cluster", domain),
wantIsExtDNS: true,
wantLabel: "legacy-app",
wantRecordType: "", // Empty means match any type
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotIsExtDNS, gotInfo := isExternalDNSTxtRecord(tt.record, domain, "")
if gotIsExtDNS != tt.wantIsExtDNS {
t.Errorf("isExternalDNSTxtRecord() isExtDNS = %v, want %v", gotIsExtDNS, tt.wantIsExtDNS)
}
if tt.wantIsExtDNS {
if gotInfo == nil {
t.Errorf("isExternalDNSTxtRecord() returned nil info for external-dns record")
return
}
if gotInfo.Label != tt.wantLabel {
t.Errorf("isExternalDNSTxtRecord() label = %q, want %q", gotInfo.Label, tt.wantLabel)
}
if gotInfo.RecordType != tt.wantRecordType {
t.Errorf("isExternalDNSTxtRecord() recordType = %q, want %q", gotInfo.RecordType, tt.wantRecordType)
}
}
})
}
}
func TestParseExternalDNSTxtLabel(t *testing.T) {
tests := []struct {
name string
label string
wantLabel string
wantRecordType string
}{
{
name: "A record prefix",
label: "a-myapp",
wantLabel: "myapp",
wantRecordType: "A",
},
{
name: "AAAA record prefix",
label: "aaaa-myapp",
wantLabel: "myapp",
wantRecordType: "AAAA",
},
{
name: "CNAME record prefix",
label: "cname-www",
wantLabel: "www",
wantRecordType: "CNAME",
},
{
name: "NS record prefix",
label: "ns-subdomain",
wantLabel: "subdomain",
wantRecordType: "NS",
},
{
name: "MX record prefix",
label: "mx-mail",
wantLabel: "mail",
wantRecordType: "MX",
},
{
name: "apex domain (A)",
label: "a-",
wantLabel: "@",
wantRecordType: "A",
},
{
name: "uppercase prefix (should handle case)",
label: "A-myapp",
wantLabel: "myapp",
wantRecordType: "A",
},
{
name: "no recognized prefix (legacy)",
label: "myapp",
wantLabel: "myapp",
wantRecordType: "",
},
{
name: "subdomain with dots in name",
label: "a-sub.domain",
wantLabel: "sub.domain",
wantRecordType: "A",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseExternalDNSTxtLabel(tt.label, "")
if got.Label != tt.wantLabel {
t.Errorf("parseExternalDNSTxtLabel() Label = %q, want %q", got.Label, tt.wantLabel)
}
if got.RecordType != tt.wantRecordType {
t.Errorf("parseExternalDNSTxtLabel() RecordType = %q, want %q", got.RecordType, tt.wantRecordType)
}
})
}
}
func TestFindExternalDNSManagedRecords(t *testing.T) {
domain := "example.com"
existing := models.Records{
// External-dns managed records
makeTestRecord("a-myapp", "TXT", "heritage=external-dns,external-dns/owner=cluster1", domain),
makeTestRecord("myapp", "A", "10.0.0.1", domain),
makeTestRecord("cname-www", "TXT", "heritage=external-dns,external-dns/owner=cluster1", domain),
makeTestRecord("www", "CNAME", "myapp.example.com.", domain),
// Non-external-dns records
makeTestRecord("static", "A", "1.2.3.4", domain),
makeTestRecord("@", "TXT", "v=spf1 -all", domain),
makeTestRecord("@", "MX", "mail.example.com.", domain),
}
managed := findExternalDNSManagedRecords(existing, domain, "")
// Check that expected keys are present
expectedKeys := []string{
"a-myapp:TXT", // The TXT record itself
"myapp:A", // The A record it manages
"cname-www:TXT", // The TXT record itself
"www:CNAME", // The CNAME record it manages
}
for _, key := range expectedKeys {
if !managed[key] {
t.Errorf("Expected key %q to be marked as managed", key)
}
}
// Check that non-external-dns records are not marked
notExpected := []string{
"static:A",
"@:TXT",
"@:MX",
}
for _, key := range notExpected {
if managed[key] {
t.Errorf("Key %q should not be marked as managed", key)
}
}
}
func TestGetExternalDNSIgnoredRecords(t *testing.T) {
domain := "example.com"
existing := models.Records{
// External-dns managed records
makeTestRecord("a-myapp", "TXT", "heritage=external-dns,external-dns/owner=cluster1", domain),
makeTestRecord("myapp", "A", "10.0.0.1", domain),
// Non-external-dns records
makeTestRecord("static", "A", "1.2.3.4", domain),
makeTestRecord("@", "TXT", "v=spf1 -all", domain),
}
ignored := GetExternalDNSIgnoredRecords(existing, domain, "")
if len(ignored) != 2 {
t.Errorf("Expected 2 ignored records, got %d", len(ignored))
}
// Verify the ignored records
foundTXT := false
foundA := false
for _, rec := range ignored {
if rec.GetLabel() == "a-myapp" && rec.Type == "TXT" {
foundTXT = true
}
if rec.GetLabel() == "myapp" && rec.Type == "A" {
foundA = true
}
}
if !foundTXT {
t.Error("Expected TXT record a-myapp to be ignored")
}
if !foundA {
t.Error("Expected A record myapp to be ignored")
}
}
func TestGetExternalDNSIgnoredRecords_NoExternalDNS(t *testing.T) {
domain := "example.com"
existing := models.Records{
makeTestRecord("static", "A", "1.2.3.4", domain),
makeTestRecord("@", "TXT", "v=spf1 -all", domain),
makeTestRecord("www", "CNAME", "static.example.com.", domain),
}
ignored := GetExternalDNSIgnoredRecords(existing, domain, "")
if len(ignored) != 0 {
t.Errorf("Expected 0 ignored records when no external-dns records exist, got %d", len(ignored))
}
}
func TestGetExternalDNSIgnoredRecords_LegacyFormat(t *testing.T) {
domain := "example.com"
// Legacy format: TXT record has same name as the record it manages (no type prefix)
existing := models.Records{
makeTestRecord("legacyapp", "TXT", "heritage=external-dns,external-dns/owner=old-cluster", domain),
makeTestRecord("legacyapp", "A", "10.0.0.1", domain),
makeTestRecord("legacyapp", "AAAA", "::1", domain),
}
ignored := GetExternalDNSIgnoredRecords(existing, domain, "")
// Legacy format should match the TXT and common record types
if len(ignored) < 3 {
t.Errorf("Expected at least 3 ignored records for legacy format, got %d", len(ignored))
}
}
func TestGetExternalDNSIgnoredRecords_CustomPrefix(t *testing.T) {
domain := "example.com"
// Custom prefix format: e.g., "extdns-www" for "www" record
existing := models.Records{
makeTestRecord("extdns-www", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain),
makeTestRecord("www", "A", "10.0.0.1", domain),
makeTestRecord("extdns-api", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain),
makeTestRecord("api", "CNAME", "app.example.com.", domain),
// Non-external-dns records
makeTestRecord("static", "A", "1.2.3.4", domain),
}
// Without custom prefix, only the TXT records themselves should be detected
// (but the A/CNAME won't be linked because "extdns-" isn't a known type prefix)
ignoredDefault := GetExternalDNSIgnoredRecords(existing, domain, "")
// With custom prefix, both TXT and their managed records should be detected
ignoredCustom := GetExternalDNSIgnoredRecords(existing, domain, "extdns-")
// Custom prefix should find more records
if len(ignoredCustom) <= len(ignoredDefault) {
t.Errorf("Expected custom prefix to find more records: default=%d, custom=%d",
len(ignoredDefault), len(ignoredCustom))
}
// Should find: extdns-www:TXT, www:A/AAAA/CNAME/etc, extdns-api:TXT, api:A/AAAA/CNAME/etc
if len(ignoredCustom) < 4 {
t.Errorf("Expected at least 4 ignored records with custom prefix, got %d", len(ignoredCustom))
}
}
func TestParseExternalDNSTxtLabel_CustomPrefix(t *testing.T) {
tests := []struct {
name string
label string
customPrefix string
wantLabel string
wantRecordType string
}{
{
name: "custom prefix with record type",
label: "extdns-a-myapp",
customPrefix: "extdns-",
wantLabel: "myapp",
wantRecordType: "A",
},
{
name: "custom prefix without record type",
label: "extdns-www",
customPrefix: "extdns-",
wantLabel: "www",
wantRecordType: "", // No record type in this format
},
{
name: "custom prefix apex domain",
label: "extdns-",
customPrefix: "extdns-",
wantLabel: "@",
wantRecordType: "",
},
{
name: "prefix not found - fallback to legacy",
label: "other-www",
customPrefix: "extdns-",
wantLabel: "other-www",
wantRecordType: "",
},
// Period format tests (--txt-prefix=extdns-%{record_type}.)
{
name: "period format A record",
label: "extdns-a.myapp",
customPrefix: "extdns-",
wantLabel: "myapp",
wantRecordType: "A",
},
{
name: "period format AAAA record",
label: "extdns-aaaa.myapp",
customPrefix: "extdns-",
wantLabel: "myapp",
wantRecordType: "AAAA",
},
{
name: "period format CNAME record",
label: "extdns-cname.www",
customPrefix: "extdns-",
wantLabel: "www",
wantRecordType: "CNAME",
},
{
name: "period format apex A record",
label: "extdns-a",
customPrefix: "extdns-",
wantLabel: "@",
wantRecordType: "A",
},
{
name: "period format apex AAAA record",
label: "extdns-aaaa",
customPrefix: "extdns-",
wantLabel: "@",
wantRecordType: "AAAA",
},
{
name: "period format apex CNAME record",
label: "extdns-cname",
customPrefix: "extdns-",
wantLabel: "@",
wantRecordType: "CNAME",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseExternalDNSTxtLabel(tt.label, tt.customPrefix)
if got.Label != tt.wantLabel {
t.Errorf("parseExternalDNSTxtLabel() Label = %q, want %q", got.Label, tt.wantLabel)
}
if got.RecordType != tt.wantRecordType {
t.Errorf("parseExternalDNSTxtLabel() RecordType = %q, want %q", got.RecordType, tt.wantRecordType)
}
})
}
}
// TestGetExternalDNSIgnoredRecords_PeriodFormat tests the period format used when
// external-dns is configured with --txt-prefix=prefix-%{record_type}.
// This creates TXT records like "extdns-a.www" for "www" A record.
func TestGetExternalDNSIgnoredRecords_PeriodFormat(t *testing.T) {
domain := "example.com"
existing := models.Records{
// Period format TXT records (from --txt-prefix=extdns-%{record_type}.)
makeTestRecord("extdns-a.www", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain),
makeTestRecord("www", "A", "10.0.0.1", domain),
makeTestRecord("extdns-cname.api", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain),
makeTestRecord("api", "CNAME", "app.example.com.", domain),
// Non-external-dns records
makeTestRecord("static", "A", "1.2.3.4", domain),
}
ignored := GetExternalDNSIgnoredRecords(existing, domain, "extdns-")
// Should find: extdns-a.www:TXT, www:A, extdns-cname.api:TXT, api:CNAME
if len(ignored) < 4 {
t.Errorf("Expected at least 4 ignored records with period format, got %d", len(ignored))
}
// Verify specific records are ignored
found := make(map[string]bool)
for _, rec := range ignored {
key := rec.GetLabel() + ":" + rec.Type
found[key] = true
}
expectedKeys := []string{"extdns-a.www:TXT", "www:A", "extdns-cname.api:TXT", "api:CNAME"}
for _, key := range expectedKeys {
if !found[key] {
t.Errorf("Expected record %q to be ignored", key)
}
}
}
// TestGetExternalDNSIgnoredRecords_PeriodFormatApex tests apex domain detection
// with the period format (--txt-prefix=prefix-%{record_type}.).
// For apex domains, the TXT label becomes "extdns-a" (just prefix + record type).
func TestGetExternalDNSIgnoredRecords_PeriodFormatApex(t *testing.T) {
domain := "example.com"
existing := models.Records{
// Period format apex A record: TXT at "extdns-a" manages "@" A record
makeTestRecord("extdns-a", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain),
makeTestRecord("@", "A", "10.0.0.1", domain),
// Period format apex AAAA record
makeTestRecord("extdns-aaaa", "TXT", "heritage=external-dns,external-dns/owner=k3s-cluster", domain),
makeTestRecord("@", "AAAA", "::1", domain),
// Non-external-dns apex records (should not be ignored)
makeTestRecord("@", "MX", "mail.example.com.", domain),
makeTestRecord("@", "TXT", "v=spf1 -all", domain),
}
ignored := GetExternalDNSIgnoredRecords(existing, domain, "extdns-")
// Should find: extdns-a:TXT, @:A, extdns-aaaa:TXT, @:AAAA
if len(ignored) != 4 {
t.Errorf("Expected 4 ignored records for apex with period format, got %d", len(ignored))
for _, rec := range ignored {
t.Logf(" ignored: %s:%s", rec.GetLabel(), rec.Type)
}
}
// Verify specific records are ignored
found := make(map[string]bool)
for _, rec := range ignored {
key := rec.GetLabel() + ":" + rec.Type
found[key] = true
}
expectedKeys := []string{"extdns-a:TXT", "@:A", "extdns-aaaa:TXT", "@:AAAA"}
for _, key := range expectedKeys {
if !found[key] {
t.Errorf("Expected record %q to be ignored", key)
}
}
// Verify MX and SPF TXT are NOT ignored
notExpectedKeys := []string{"@:MX", "@:TXT"}
for _, key := range notExpectedKeys {
if found[key] {
t.Errorf("Record %q should NOT be ignored", key)
}
}
}