dnscontrol/pkg/diff2/diff2.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

274 lines
9.5 KiB
Go

package diff2
//go:generate go tool stringer -type=Verb
// This module provides functions that "diff" the existing records
// against the desired records.
import (
"fmt"
"strings"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/dnsgraph"
)
// Verb indicates the Change's type (create, delete, etc.)
type Verb int
// CREATE and other verbs.
const (
_ Verb = iota // Skip the first value of 0
CREATE // Create a record/recordset/label where none existed before.
CHANGE // Change existing record/recordset/label
DELETE // Delete existing record/recordset/label
REPORT // No change, but I have something to say!
)
// ChangeList is a list of Change
type ChangeList []Change
// Change is an instruction to the provider. Generally if one properly executes
// all the changes, an "existing" zone will turn into the "desired" zone.
type Change struct {
Type Verb // Add, Change, Delete
Key models.RecordKey // .Key.Type is "" unless using ByRecordSet
Old models.Records
New models.Records // any changed or added records at Key.
Msgs []string // Human-friendly explanation of what changed
MsgsJoined string // strings.Join(Msgs, "\n")
MsgsByKey map[models.RecordKey][]string // Messages for a given key
// HintOnlyTTL is true only if (.Type == diff2.CHANGE) && (there is
// exactly 1 record being updated) && (the only change is the TTL)
HintOnlyTTL bool
// HintRecordSetLen1 is true only if (.Type == diff2.CHANGE) &&
// (there is exactly 1 record at this RecordSet).
// For example, MSDNS can use a more efficient command if it knows
// that `Get-DnsServerResourceRecord -Name FOO -RRType A` will
// return exactly one record.
HintRecordSetLen1 bool
}
// GetType returns the type of a change.
func (c Change) GetType() dnsgraph.NodeType {
if c.Type == REPORT {
return dnsgraph.Report
}
return dnsgraph.Change
}
// GetName returns the FQDN of the host being changed.
func (c Change) GetName() string {
return c.Key.NameFQDN
}
// GetDependencies returns the depenencies of a change.
func (c Change) GetDependencies() []dnsgraph.Dependency {
var dependencies []dnsgraph.Dependency
if c.Type == CHANGE || c.Type == DELETE {
dependencies = append(dependencies, dnsgraph.CreateDependencies(c.Old.GetAllDependencies(), dnsgraph.BackwardDependency)...)
}
if c.Type == CHANGE || c.Type == CREATE {
dependencies = append(dependencies, dnsgraph.CreateDependencies(c.New.GetAllDependencies(), dnsgraph.ForwardDependency)...)
}
return dependencies
}
/*
General instructions:
changes, err := diff2.ByRecord(existing, dc, nil)
//changes, err := diff2.ByRecordSet(existing, dc, nil)
//changes, err := diff2.ByLabel(existing, dc, nil)
if err != nil {
return nil, err
}
var corrections []*models.Correction
for _, change := range changes {
switch change.Type {
case diff2.REPORT:
corr = change.CreateMessage()
case diff2.CREATE:
corr = change.CreateCorrection(func() error { return c.createRecord(FILL_IN) })
case diff2.CHANGE:
corr = change.CreateCorrection(func() error { return c.modifyRecord(FILL_IN) })
case diff2.DELETE:
corr = change.CreateCorrection(func() error { return c.deleteRecord(FILL_IN) })
default:
panic("unhandled change.TYPE %s", change.Type)
}
corrections = append(corrections, corr)
}
return corrections, nil
}
*/
// CreateCorrection creates a new Correction based on the given
// function and prefills it with the Msg of the current Change
func (c *Change) CreateCorrection(correctionFunction func() error) *models.Correction {
return &models.Correction{
F: correctionFunction,
Msg: c.MsgsJoined,
}
}
// CreateMessage creates a new correction with only the message.
// Used for diff2.Report corrections
func (c *Change) CreateMessage() *models.Correction {
return &models.Correction{
Msg: c.MsgsJoined,
}
}
// CreateCorrectionWithMessage creates a new Correction based on the
// given function and prefixes given function with the Msg of the
// current change
func (c *Change) CreateCorrectionWithMessage(msg string, correctionFunction func() error) *models.Correction {
return &models.Correction{
F: correctionFunction,
Msg: fmt.Sprintf("%s: %s", msg, c.MsgsJoined),
}
}
// ByRecordSet takes two lists of records (existing and desired) and
// returns instructions for turning existing into desired.
//
// Use this with DNS providers whose API updates one recordset at a
// time. A recordset is all the records of a particular type at a
// label. For example, if www.example.com has 3 A records and a TXT
// record, if A records are added, changed, or removed, the API takes
// www.example.com, A, and a list of all the desired IP addresses.
//
// Examples include: AZURE_DNS, GCORE, NS1, ROUTE53
func ByRecordSet(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, int, error) {
return byHelper(analyzeByRecordSet, existing, dc, compFunc)
}
// ByLabel takes two lists of records (existing and desired) and
// returns instructions for turning existing into desired.
//
// Use this with DNS providers whose API updates one label at a
// time. That is, updates are done by sending a list of DNS records
// to be served at a particular label, or the label itself is deleted.
//
// Examples include: GANDI_V5
func ByLabel(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, int, error) {
return byHelper(analyzeByLabel, existing, dc, compFunc)
}
// ByRecord takes two lists of records (existing and desired) and
// returns instructions for turning existing into desired.
//
// Use this with DNS providers whose API updates one record at a time.
//
// Note: The .Old and .New field are lists ([]models.RecordConfig) but
// when using ByRecord() they will never have more than one entry.
// A create always has exactly 1 new: .New[0]
// A change always has exactly 1 old and 1 new: .Old[0] and .New[0]
// A delete always has exactly 1 old: .Old[0]
//
// Examples include: CLOUDFLAREAPI, HEDNS, INWX, MSDNS, OVH, PORKBUN, VULTR
func ByRecord(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, int, error) {
return byHelper(analyzeByRecord, existing, dc, compFunc)
}
// ByZone takes two lists of records (existing and desired) and
// returns text to output to users describing the change, a bool
// indicating if there were any changes, and a possible err value.
//
// Use this with DNS providers whose API updates the entire zone at a
// time. That is, to make any change (even just 1 record) the entire DNS
// zone is uploaded.
//
// The user should see a list of changes as if individual records were updated.
//
// Example usage:
//
// result, err := diff2.ByZone(foundRecords, dc, nil)
// if err != nil {
// return nil, err
// }
// if changes {
// // Generate a "correction" that uploads the entire zone.
// // (result.DesiredPlus are the new records for the zone).
// }
//
// Example providers include: BIND, AUTODNS
func ByZone(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ByResults, error) {
// Only return the messages and a list of records needed to build the new zone.
result, err := byHelperStruct(analyzeByRecord, existing, dc, compFunc)
result.Msgs = justMsgs(result.Instructions)
return result, err
}
// ByResults is the results of ByZone() and perhaps someday all the By*() functions.
// It is partially populated by // byHelperStruct() and partially by the By*()
// functions that use it.
type ByResults struct {
// Fields filled in by byHelperStruct():
Instructions ChangeList // Instructions to turn existing into desired.
ActualChangeCount int // Number of actual changes, not including REPORTs.
HasChanges bool // True if there are any changes.
DesiredPlus models.Records // Desired + foreign + ignored
// Fields filled in by ByZone():
Msgs []string // Just the messages from the instructions.
}
// byHelper is like byHelperStruct but has a signature that is compatible with legacy code.
// Deprecated: Use byHelperStruct instead.
func byHelper(fn func(cc *CompareConfig) (ChangeList, int), existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, int, error) {
result, err := byHelperStruct(fn, existing, dc, compFunc)
return result.Instructions, result.ActualChangeCount, err
}
// byHelperStruct does 90% of the work for the By*() calls.
func byHelperStruct(fn func(cc *CompareConfig) (ChangeList, int), existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ByResults, error) {
// Process NO_PURGE/ENSURE_ABSENT and IGNORE*().
desiredPlus, msgs, err := handsoff(
dc.Name,
existing, dc.Records, dc.EnsureAbsent,
dc.Unmanaged,
dc.UnmanagedUnsafe,
dc.KeepUnknown,
dc.IgnoreExternalDNS,
dc.ExternalDNSPrefix,
)
if err != nil {
return ByResults{}, err
}
// Regroup existing/desiredd for easy comparison:
cc := NewCompareConfig(dc.Name, existing, desiredPlus, compFunc)
// Analyze and generate the instructions:
instructions, actualChangeCount := fn(cc)
// If we have msgs, create a change to output them:
if len(msgs) != 0 {
chg := Change{
Type: REPORT,
Msgs: msgs,
MsgsJoined: strings.Join(msgs, "\n"),
}
_ = chg
instructions = append([]Change{chg}, instructions...)
}
return ByResults{
Instructions: instructions,
ActualChangeCount: actualChangeCount,
HasChanges: actualChangeCount > 0,
DesiredPlus: desiredPlus,
}, nil
}