dnscontrol/pkg/diff2/handsoff.go
2023-12-13 12:32:39 -05:00

278 lines
9.5 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 (
"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 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,
) (models.Records, []string, error) {
var msgs []string
// Prep the globs:
err := compileUnmanagedConfigs(unmanagedConfigs)
if err != nil {
return nil, nil, err
}
var punct = ":"
if printer.MaxReport == 0 {
punct = "."
}
// Process IGNORE*() and NO_PURGE features:
ignorable, foreign := processIgnoreAndNoPurge(domain, existing, desired, absences, unmanagedConfigs, noPurge)
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, fmt.Errorf(strings.Join(msgs, "\n") +
"\nERROR: Unsafe to continue. Add DISABLE_IGNORE_SAFETY_CHECK to D() to override")
}
}
// Add the ignored/foreign items to the desired list so they are not deleted:
desired = append(desired, ignorable...)
desired = append(desired, foreign...)
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) {
var ignorable, foreign models.Records
desiredDB := models.NewRecordDBFromRecords(desired, domain)
absentDB := models.NewRecordDBFromRecords(absences, domain)
compileUnmanagedConfigs(unmanagedConfigs)
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
}
// 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.Split(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)
}