mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-12-31 13:16:14 +08:00
# Issue We haven't run `moderize` on the codebase in a while. There have been many updates to the Go language since. # Resolution Run it. Test it.
362 lines
12 KiB
Go
362 lines
12 KiB
Go
package diff2
|
|
|
|
// This file implements the features that tell DNSControl "hands off"
|
|
// foreign-controlled (or shared-control) DNS records. i.e. the
|
|
// NO_PURGE, ENSURE_ABSENT and IGNORE*() features.
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/StackExchange/dnscontrol/v4/models"
|
|
"github.com/StackExchange/dnscontrol/v4/pkg/printer"
|
|
"github.com/gobwas/glob"
|
|
)
|
|
|
|
/*
|
|
|
|
# How do NO_PURGE, IGNORE*() and ENSURE_ABSENT work?
|
|
|
|
## Terminology:
|
|
|
|
* "existing" refers to the records downloaded from the provider via the API.
|
|
* "desired" refers to the records generated from dnsconfig.js.
|
|
* "absences" refers to a list of records tagged with ENSURE_ABSENT.
|
|
|
|
## What are the features?
|
|
|
|
There are 2 ways to tell DNSControl not to touch existing records in a domain,
|
|
and 1 way to make exceptions.
|
|
|
|
* NO_PURGE: Tells DNSControl not to delete records in a domain.
|
|
* New records will be created
|
|
* Existing records (matched on label:rtype) will be modified.
|
|
* FYI: This means you can't have a label with two A records, one controlled
|
|
by DNSControl and one controlled by an external system.
|
|
* IGNORE(labelglob, typelist, targetglob):
|
|
* "If an existing record matches this pattern, don't touch it!""
|
|
* IGNORE_NAME(foo, bar) is the same as IGNORE(foo, bar, "*")
|
|
* IGNORE_TARGET(foo) is the same as IGNORE("*", "*", foo)
|
|
* FYI: You CAN have a label with two A records, one controlled by
|
|
DNSControl and one controlled by an external system. DNSControl would
|
|
need to have an IGNORE() statement with a targetglob that matches
|
|
the external system's target values.
|
|
* ENSURE_ABSENT: Override NO_PURGE for specific records. i.e. delete them even
|
|
though NO_PURGE is enabled.
|
|
* If any of these records are in desired (matched on
|
|
label:rtype:target), remove them. This takes priority over
|
|
NO_PURGE/IGNORE*().
|
|
|
|
## Implementation premise
|
|
|
|
The fundamental premise is "if you don't want it deleted, copy it to the
|
|
'desired' list." So, for example, if you want to IGNORE_NAME("www"), then you
|
|
find any records with the label "www" in "existing" and copy them to "desired".
|
|
As a result, the diff2 algorithm won't delete them because they are desired!
|
|
(Of course "desired" can't have duplicate records. Check before you add.)
|
|
|
|
This is different than in the old implementation (pkg/diff) which would generate the
|
|
diff but then do a bunch of checking to see if the record was one that
|
|
shouldn't be deleted. Or, in the case of NO_PURGE, would simply not do the
|
|
deletions. This was complex because there were many edge cases to deal with.
|
|
It was often also wrong. For example, if a provider updates all records in a
|
|
RecordSet at once, you shouldn't NOT update the record.
|
|
|
|
## Implementation
|
|
|
|
Here is how we intend to implement these features:
|
|
|
|
IGNORE() is implemented as:
|
|
* Take the list of existing records. If any match one of the IGNORE glob
|
|
patterns, add it to the "ignored list".
|
|
* If any item on the "ignored list" is also in "desired" (match on
|
|
label:rtype), output a warning (defeault) or declare an error (if
|
|
DISABLE_IGNORE_SAFETY_CHECK is true).
|
|
* When we're done, add the "ignore list" records to desired.
|
|
|
|
NO_PURGE + ENSURE_ABSENT is implemented as:
|
|
* Take the list of existing records. If any do not appear in desired, add them
|
|
to desired UNLESS they appear in absences. (Yes, that's complex!)
|
|
* "appear in desired" is done by matching on label:type.
|
|
* "appear in absences" is done by matching on label:type:target.
|
|
|
|
The actual implementation combines this all into one loop:
|
|
foreach rec in existing:
|
|
if rec matches_any_unmanaged_pattern:
|
|
if rec in desired:
|
|
if "DISABLE_IGNORE_SAFETY_CHECK" is false:
|
|
Display a warning.
|
|
else
|
|
Return an error.
|
|
Add rec to "ignored list"
|
|
else:
|
|
if NO_PURGE:
|
|
if rec NOT in desired: (matched on label:type)
|
|
if rec NOT in absences: (matched on label:type:target)
|
|
Add rec to "foreign list"
|
|
Append "ignored list" to "desired".
|
|
Append "foreign list" to "desired".
|
|
*/
|
|
|
|
// handsoff processes the IGNORE*()//NO_PURGE/ENSURE_ABSENT features.
|
|
func handsoff(
|
|
domain string,
|
|
existing, desired, absences models.Records,
|
|
unmanagedConfigs []*models.UnmanagedConfig,
|
|
unmanagedSafely bool,
|
|
noPurge bool,
|
|
ignoreExternalDNS bool,
|
|
externalDNSPrefix string,
|
|
) (models.Records, []string, error) {
|
|
var msgs []string
|
|
|
|
// Prep the globs:
|
|
err := compileUnmanagedConfigs(unmanagedConfigs)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
punct := ":"
|
|
if printer.MaxReport == 0 {
|
|
punct = "."
|
|
}
|
|
|
|
// Process IGNORE_EXTERNAL_DNS feature:
|
|
var externalDNSIgnored models.Records
|
|
if ignoreExternalDNS {
|
|
externalDNSIgnored = GetExternalDNSIgnoredRecords(existing, domain, externalDNSPrefix)
|
|
if len(externalDNSIgnored) != 0 {
|
|
msgs = append(msgs, fmt.Sprintf("%d records not being deleted because of IGNORE_EXTERNAL_DNS%s", len(externalDNSIgnored), punct))
|
|
msgs = append(msgs, reportSkips(externalDNSIgnored, !printer.SkinnyReport)...)
|
|
}
|
|
}
|
|
|
|
// Process IGNORE*() and NO_PURGE features:
|
|
ignorable, foreign, err := processIgnoreAndNoPurge(domain, existing, desired, absences, unmanagedConfigs, noPurge)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if len(foreign) != 0 {
|
|
msgs = append(msgs, fmt.Sprintf("%d records not being deleted because of NO_PURGE%s", len(foreign), punct))
|
|
msgs = append(msgs, reportSkips(foreign, !printer.SkinnyReport)...)
|
|
}
|
|
if len(ignorable) != 0 {
|
|
msgs = append(msgs, fmt.Sprintf("%d records not being deleted because of IGNORE*()%s", len(ignorable), punct))
|
|
msgs = append(msgs, reportSkips(ignorable, !printer.SkinnyReport)...)
|
|
}
|
|
|
|
// Check for invalid use of IGNORE_*.
|
|
conflicts := findConflicts(unmanagedConfigs, desired)
|
|
if len(conflicts) != 0 {
|
|
msgs = append(msgs, fmt.Sprintf("%d records that are both IGNORE*()'d and not ignored:", len(conflicts)))
|
|
for _, r := range conflicts {
|
|
msgs = append(msgs, fmt.Sprintf(" %s %s %s", r.GetLabelFQDN(), r.Type, r.GetTargetCombined()))
|
|
}
|
|
if !unmanagedSafely {
|
|
return nil, nil, errors.New(strings.Join(msgs, "\n") +
|
|
"\nERROR: Unsafe to continue. Add DISABLE_IGNORE_SAFETY_CHECK to D() to override")
|
|
}
|
|
}
|
|
|
|
// Check for conflicts between desired records and external-dns managed records.
|
|
// This warns users when they define a record that external-dns is also managing.
|
|
if ignoreExternalDNS && len(externalDNSIgnored) > 0 {
|
|
externalDNSConflicts := findExternalDNSConflicts(desired, externalDNSIgnored)
|
|
if len(externalDNSConflicts) != 0 {
|
|
msgs = append(msgs, fmt.Sprintf("WARNING: %d records are defined in your config but also managed by external-dns:", len(externalDNSConflicts)))
|
|
for _, r := range externalDNSConflicts {
|
|
msgs = append(msgs, fmt.Sprintf(" %s %s %s", r.GetLabelFQDN(), r.Type, r.GetTargetCombined()))
|
|
}
|
|
msgs = append(msgs, "Consider removing these from your config or from external-dns to avoid conflicts.")
|
|
}
|
|
// Filter out conflicts from externalDNSIgnored to avoid duplicates in desired
|
|
externalDNSIgnored = filterOutConflicts(externalDNSIgnored, externalDNSConflicts)
|
|
}
|
|
|
|
// Add the ignored/foreign items to the desired list so they are not deleted:
|
|
desired = append(desired, ignorable...)
|
|
desired = append(desired, foreign...)
|
|
desired = append(desired, externalDNSIgnored...)
|
|
return desired, msgs, nil
|
|
}
|
|
|
|
// reportSkips reports records being skipped, if !full only the first
|
|
// printer.MaxReport are output.
|
|
func reportSkips(recs models.Records, full bool) []string {
|
|
var msgs []string
|
|
|
|
shorten := (!full) && (len(recs) > printer.MaxReport)
|
|
|
|
last := len(recs)
|
|
if shorten {
|
|
last = printer.MaxReport
|
|
}
|
|
|
|
for _, r := range recs[:last] {
|
|
msgs = append(msgs, fmt.Sprintf(" %s. %s %s", r.GetLabelFQDN(), r.Type, r.GetTargetCombined()))
|
|
}
|
|
if shorten && printer.MaxReport != 0 {
|
|
msgs = append(msgs, fmt.Sprintf(" ...and %d more... (use --full to show all)", len(recs)-printer.MaxReport))
|
|
}
|
|
|
|
return msgs
|
|
}
|
|
|
|
// processIgnoreAndNoPurge processes the IGNORE_*() and NO_PURGE/ENSURE_ABSENT() features.
|
|
func processIgnoreAndNoPurge(domain string, existing, desired, absences models.Records, unmanagedConfigs []*models.UnmanagedConfig, noPurge bool) (models.Records, models.Records, error) {
|
|
var ignorable, foreign models.Records
|
|
desiredDB := models.NewRecordDBFromRecords(desired, domain)
|
|
absentDB := models.NewRecordDBFromRecords(absences, domain)
|
|
if err := compileUnmanagedConfigs(unmanagedConfigs); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for _, rec := range existing {
|
|
isMatch := matchAny(unmanagedConfigs, rec)
|
|
// fmt.Printf("DEBUG: matchAny returned: %v\n", isMatch)
|
|
if isMatch {
|
|
ignorable = append(ignorable, rec)
|
|
} else {
|
|
if noPurge {
|
|
// Is this a candidate for purging?
|
|
if !desiredDB.ContainsLT(rec) {
|
|
// Yes, but not if it is an exception!
|
|
if !absentDB.ContainsLT(rec) {
|
|
foreign = append(foreign, rec)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ignorable, foreign, nil
|
|
}
|
|
|
|
// findConflicts takes a list of recs and a list of (compiled) UnmanagedConfigs
|
|
// and reports if any of the recs match any of the configs.
|
|
func findConflicts(uconfigs []*models.UnmanagedConfig, recs models.Records) models.Records {
|
|
var conflicts models.Records
|
|
for _, rec := range recs {
|
|
if matchAny(uconfigs, rec) {
|
|
conflicts = append(conflicts, rec)
|
|
}
|
|
}
|
|
return conflicts
|
|
}
|
|
|
|
// compileUnmanagedConfigs prepares a slice of UnmanagedConfigs so they can be used.
|
|
func compileUnmanagedConfigs(configs []*models.UnmanagedConfig) error {
|
|
var err error
|
|
|
|
for i := range configs {
|
|
c := configs[i]
|
|
|
|
if c.LabelPattern == "" || c.LabelPattern == "*" {
|
|
c.LabelGlob = nil // nil indicates "always match"
|
|
} else {
|
|
c.LabelGlob, err = glob.Compile(c.LabelPattern)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
c.RTypeMap = make(map[string]struct{})
|
|
if c.RTypePattern != "*" && c.RTypePattern != "" {
|
|
for part := range strings.SplitSeq(c.RTypePattern, ",") {
|
|
part = strings.TrimSpace(part)
|
|
c.RTypeMap[part] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if c.TargetPattern == "" || c.TargetPattern == "*" {
|
|
c.TargetGlob = nil // nil indicates "always match"
|
|
} else {
|
|
c.TargetGlob, err = glob.Compile(c.TargetPattern)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// matchAny returns true if rec matches any of the uconfigs.
|
|
func matchAny(uconfigs []*models.UnmanagedConfig, rec *models.RecordConfig) bool {
|
|
// fmt.Printf("DEBUG: matchAny(%s, %q, %q, %q)\n", models.DebugUnmanagedConfig(uconfigs), rec.NameFQDN, rec.Type, rec.GetTargetField())
|
|
for _, uc := range uconfigs {
|
|
if matchLabel(uc.LabelGlob, rec.GetLabel()) &&
|
|
matchType(uc.RTypeMap, rec.Type) &&
|
|
matchTarget(uc.TargetGlob, rec.GetTargetField()) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func matchLabel(labelGlob glob.Glob, labelName string) bool {
|
|
if labelGlob == nil {
|
|
return true
|
|
}
|
|
return labelGlob.Match(labelName)
|
|
}
|
|
|
|
func matchType(typeMap map[string]struct{}, typeName string) bool {
|
|
if len(typeMap) == 0 {
|
|
return true
|
|
}
|
|
_, ok := typeMap[typeName]
|
|
return ok
|
|
}
|
|
|
|
func matchTarget(targetGlob glob.Glob, targetName string) bool {
|
|
if targetGlob == nil {
|
|
return true
|
|
}
|
|
return targetGlob.Match(targetName)
|
|
}
|
|
|
|
// findExternalDNSConflicts returns records that appear in both desired and externalDNSIgnored.
|
|
// This helps identify when a user has defined a record in their config that is also
|
|
// being managed by external-dns.
|
|
func findExternalDNSConflicts(desired, externalDNSIgnored models.Records) models.Records {
|
|
// Build a map of desired records keyed by label:type
|
|
desiredMap := make(map[string]bool)
|
|
for _, rec := range desired {
|
|
key := rec.GetLabel() + ":" + rec.Type
|
|
desiredMap[key] = true
|
|
}
|
|
|
|
// Find any external-dns ignored records that are also in desired
|
|
var conflicts models.Records
|
|
for _, rec := range externalDNSIgnored {
|
|
key := rec.GetLabel() + ":" + rec.Type
|
|
if desiredMap[key] {
|
|
conflicts = append(conflicts, rec)
|
|
}
|
|
}
|
|
return conflicts
|
|
}
|
|
|
|
// filterOutConflicts removes records from externalDNSIgnored that are in conflicts.
|
|
// This prevents duplicates when appending externalDNSIgnored to desired.
|
|
func filterOutConflicts(externalDNSIgnored, conflicts models.Records) models.Records {
|
|
if len(conflicts) == 0 {
|
|
return externalDNSIgnored
|
|
}
|
|
|
|
// Build a set of conflict keys
|
|
conflictSet := make(map[string]bool)
|
|
for _, rec := range conflicts {
|
|
key := rec.GetLabel() + ":" + rec.Type
|
|
conflictSet[key] = true
|
|
}
|
|
|
|
// Filter out conflicts
|
|
var filtered models.Records
|
|
for _, rec := range externalDNSIgnored {
|
|
key := rec.GetLabel() + ":" + rec.Type
|
|
if !conflictSet[key] {
|
|
filtered = append(filtered, rec)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|