mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2024-11-14 20:24:39 +08:00
278 lines
9.5 KiB
Go
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)
|
|
}
|