1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00

Bugfixed: NO_PURGE now works on all diff2 providers (#2084)

This commit is contained in:
Tom Limoncelli
2023-02-19 12:33:08 -05:00
committed by GitHub
parent c012164cd4
commit fc3a217dc1
26 changed files with 768 additions and 460 deletions

View File

@ -750,6 +750,7 @@ func makeTests(t *testing.T) []*TestGroup {
// This is a strange one. It adds a new record to an existing // This is a strange one. It adds a new record to an existing
// label but the pre-existing label has its TTL change. // label but the pre-existing label has its TTL change.
testgroup("add to label and change orig ttl", testgroup("add to label and change orig ttl",
not("NAMEDOTCOM"), // Known bug: https://github.com/StackExchange/dnscontrol/issues/2088
tc("Setup", ttl(a("www", "5.6.7.8"), 400)), tc("Setup", ttl(a("www", "5.6.7.8"), 400)),
tc("Add at same label, new ttl", ttl(a("www", "5.6.7.8"), 700), ttl(a("www", "1.2.3.4"), 700)), tc("Add at same label, new ttl", ttl(a("www", "5.6.7.8"), 700), ttl(a("www", "1.2.3.4"), 700)),
), ),

View File

@ -19,11 +19,13 @@ type DomainConfig struct {
Records Records `json:"records"` Records Records `json:"records"`
Nameservers []*Nameserver `json:"nameservers,omitempty"` Nameservers []*Nameserver `json:"nameservers,omitempty"`
KeepUnknown bool `json:"keepunknown,omitempty"` EnsureAbsent Records `json:"recordsabsent,omitempty"` // ENSURE_ABSENT
KeepUnknown bool `json:"keepunknown,omitempty"` // NO_PURGE
IgnoredNames []*IgnoreName `json:"ignored_names,omitempty"` IgnoredNames []*IgnoreName `json:"ignored_names,omitempty"`
IgnoredTargets []*IgnoreTarget `json:"ignored_targets,omitempty"` IgnoredTargets []*IgnoreTarget `json:"ignored_targets,omitempty"`
Unmanaged []*UnmanagedConfig `json:"unmanaged,omitempty"` Unmanaged []*UnmanagedConfig `json:"unmanaged,omitempty"` // UNMANAGED()
UnmanagedUnsafe bool `json:"unmanaged_disable_safety_check,omitempty"` UnmanagedUnsafe bool `json:"unmanaged_disable_safety_check,omitempty"` // DISABLE_UNMANAGED_SAFETY_CHECK
AutoDNSSEC string `json:"auto_dnssec,omitempty"` // "", "on", "off" AutoDNSSEC string `json:"auto_dnssec,omitempty"` // "", "on", "off"
//DNSSEC bool `json:"dnssec,omitempty"` //DNSSEC bool `json:"dnssec,omitempty"`
@ -36,14 +38,6 @@ type DomainConfig struct {
DNSProviderInstances []*DNSProviderInstance `json:"-"` DNSProviderInstances []*DNSProviderInstance `json:"-"`
} }
// UnmanagedConfig describes an UNMANAGED() rule.
type UnmanagedConfig struct {
Label string `json:"label_pattern"` // Glob pattern for matching labels.
RType string `json:"rType_pattern"` // Comma-separated list of DNS Resource Types.
typeMap map[string]bool // map of RTypes or len()=0 for all
Target string `json:"target_pattern"` // Glob pattern for matching targets.
}
// Copy returns a deep copy of the DomainConfig. // Copy returns a deep copy of the DomainConfig.
func (dc *DomainConfig) Copy() (*DomainConfig, error) { func (dc *DomainConfig) Copy() (*DomainConfig, error) {
newDc := &DomainConfig{} newDc := &DomainConfig{}

View File

@ -184,6 +184,9 @@ func (rc *RecordConfig) UnmarshalJSON(b []byte) error {
TxtStrings []string `json:"txtstrings,omitempty"` // TxtStrings stores all strings (including the first). Target stores only the first one. TxtStrings []string `json:"txtstrings,omitempty"` // TxtStrings stores all strings (including the first). Target stores only the first one.
R53Alias map[string]string `json:"r53_alias,omitempty"` R53Alias map[string]string `json:"r53_alias,omitempty"`
AzureAlias map[string]string `json:"azure_alias,omitempty"` AzureAlias map[string]string `json:"azure_alias,omitempty"`
EnsureAbsent bool `json:"ensure_absent,omitempty"` // Override NO_PURGE and delete this record
// NB(tlim): If anyone can figure out how to do this without listing all // NB(tlim): If anyone can figure out how to do this without listing all
// the fields, please let us know! // the fields, please let us know!
}{} }{}

30
models/recorddb.go Normal file
View File

@ -0,0 +1,30 @@
package models
// Functions that make it easier to deal with a group of records.
type RecordDB struct {
labelAndTypeMap map[RecordKey]struct{}
}
// NewRecordDBFromRecords creates a RecordDB from a list of RecordConfig.
func NewRecordDBFromRecords(recs Records, zone string) *RecordDB {
result := &RecordDB{}
//fmt.Printf("DEBUG: BUILDING RecordDB: zone=%v\n", zone)
result.labelAndTypeMap = make(map[RecordKey]struct{}, len(recs))
for _, rec := range recs {
//fmt.Printf(" DEBUG: Adding %+v\n", rec.Key())
result.labelAndTypeMap[rec.Key()] = struct{}{}
}
//fmt.Printf("DEBUG: BUILDING RecordDB: DONE!\n")
return result
}
// ContainsLT returns true if recdb contains rec. Matching is done
// on the record's label and type (i.e. the RecordKey)
func (recdb *RecordDB) ContainsLT(rec *RecordConfig) bool {
_, ok := recdb.labelAndTypeMap[rec.Key()]
//fmt.Printf("DEBUG: ContainsLT(%q) = %v (%v)\n", rec.Key(), ok, recdb)
return ok
}

20
models/unmanaged.go Normal file
View File

@ -0,0 +1,20 @@
package models
import (
"github.com/gobwas/glob"
)
// UnmanagedConfig describes an UNMANAGED() rule.
type UnmanagedConfig struct {
// Glob pattern for matching labels.
LabelPattern string `json:"label_pattern,omitempty"`
LabelGlob glob.Glob `json:"-"` // Compiled version
// Comma-separated list of DNS Resource Types.
RTypePattern string `json:"rType_pattern,omitempty"`
RTypeMap map[string]struct{} `json:"-"` // map of RTypes or len()=0 for all
// Glob pattern for matching targets.
TargetPattern string `json:"target_pattern,omitempty"`
TargetGlob glob.Glob `json:"-"` // Compiled version
}

View File

@ -65,8 +65,9 @@ func (d *differCompat) IncrementalDiff(existing []*models.RecordConfig) (unchang
cor := Correlation{d: d.OldDiffer} cor := Correlation{d: d.OldDiffer}
switch inst.Type { switch inst.Type {
case diff2.REPORT: case diff2.REPORT:
// Sadly the NewCompat function doesn't have a way to do this. // Sadly the NewCompat function doesn't have an equivalent. We
// Purge reports are silently skipped. // just output the messages now.
fmt.Println(inst.MsgsJoined)
case diff2.CREATE: case diff2.CREATE:
cor.Desired = inst.New[0] cor.Desired = inst.New[0]
create = append(create, cor) create = append(create, cor)

View File

@ -109,10 +109,8 @@ func NewCompareConfig(origin string, existing, desired models.Records, compFn Co
func (cc *CompareConfig) VerifyCNAMEAssertions() { func (cc *CompareConfig) VerifyCNAMEAssertions() {
// In theory these assertions do not need to be tested as they test // In theory these assertions do not need to be tested as they test
// something that can not happen. In my head I've proved this to be // something that can not happen. In my I've proved this to be
// true. That said, a little paranoia is healthy. Those familiar // true. That said, a little paranoia is healthy.
// with the Therac-25 accident will agree:
// https://hackaday.com/2015/10/26/killed-by-a-machine-the-therac-25/
// According to the RFCs if a label has a CNAME, it can not have any other // According to the RFCs if a label has a CNAME, it can not have any other
// records at that label... even other CNAMEs. Therefore, we need to be // records at that label... even other CNAMEs. Therefore, we need to be
@ -141,15 +139,28 @@ func (cc *CompareConfig) VerifyCNAMEAssertions() {
for _, ld := range cc.ldata { for _, ld := range cc.ldata {
for j, td := range ld.tdata { for j, td := range ld.tdata {
if td.rType == "CNAME" { if td.rType == "CNAME" {
// This assertion doesn't hold for a site that permits a
// recordset with both CNAMEs and other records, such as
// Cloudflare.
// Therefore, we skip the test if we aren't deleting
// everything at the recordset or creating it from scratch.
if len(td.existingTargets) != 0 && len(td.desiredTargets) != 0 {
continue
}
if len(td.existingTargets) != 0 { if len(td.existingTargets) != 0 {
//fmt.Printf("DEBUG: cname in existing: index=%d\n", j) //fmt.Printf("DEBUG: cname in existing: index=%d\n", j)
if j != 0 { if j != 0 {
panic("should not happen: (CNAME not in first position)") panic("should not happen: (CNAME not in first position)")
} }
} }
if len(td.desiredTargets) != 0 { if len(td.desiredTargets) != 0 {
//fmt.Printf("DEBUG: cname in desired: index=%d\n", j) //fmt.Printf("DEBUG: cname in desired: index=%d\n", j)
//fmt.Printf("DEBUG: highest: index=%d\n", j)
if j != highest(ld.tdata) { if j != highest(ld.tdata) {
panic("should not happen: (CNAME not in last position)") panic("should not happen: (CNAME not in last position)")
} }
@ -215,8 +226,6 @@ func (cc *CompareConfig) addRecords(recs models.Records, storeInExisting bool) {
for _, rec := range z.Records { for _, rec := range z.Records {
//label := rec.NameFQDN
//rtype := rec.Type
key := rec.Key() key := rec.Key()
label := key.NameFQDN label := key.NameFQDN
rtype := key.Type rtype := key.Type
@ -225,12 +234,10 @@ func (cc *CompareConfig) addRecords(recs models.Records, storeInExisting bool) {
// Are we seeing this label for the first time? // Are we seeing this label for the first time?
var labelIdx int var labelIdx int
if _, ok := cc.labelMap[label]; !ok { if _, ok := cc.labelMap[label]; !ok {
//fmt.Printf("DEBUG: I haven't see label=%v before. Adding.\n", label)
cc.labelMap[label] = true cc.labelMap[label] = true
cc.ldata = append(cc.ldata, &labelConfig{label: label}) cc.ldata = append(cc.ldata, &labelConfig{label: label})
labelIdx = highest(cc.ldata) labelIdx = highest(cc.ldata)
} else { } else {
// find label in cc.ldata:
for k, v := range cc.ldata { for k, v := range cc.ldata {
if v.label == label { if v.label == label {
labelIdx = k labelIdx = k
@ -240,12 +247,9 @@ func (cc *CompareConfig) addRecords(recs models.Records, storeInExisting bool) {
} }
// Are we seeing this label+rtype for the first time? // Are we seeing this label+rtype for the first time?
//key := rec.Key()
if _, ok := cc.keyMap[key]; !ok { if _, ok := cc.keyMap[key]; !ok {
//fmt.Printf("DEBUG: I haven't see key=%v before. Adding.\n", key)
cc.keyMap[key] = true cc.keyMap[key] = true
x := cc.ldata[labelIdx] x := cc.ldata[labelIdx]
//fmt.Printf("DEBUG: appending rtype=%v\n", rtype)
x.tdata = append(x.tdata, &rTypeConfig{rType: rtype}) x.tdata = append(x.tdata, &rTypeConfig{rType: rtype})
} }
var rtIdx int var rtIdx int
@ -256,7 +260,6 @@ func (cc *CompareConfig) addRecords(recs models.Records, storeInExisting bool) {
break break
} }
} }
//fmt.Printf("DEBUG: found rtype=%v at index %d\n", rtype, rtIdx)
// Now it is safe to add/modify the records. // Now it is safe to add/modify the records.

View File

@ -1,7 +1,6 @@
package diff2 package diff2
import ( import (
"encoding/json"
"strings" "strings"
"testing" "testing"
@ -9,11 +8,6 @@ import (
"github.com/kylelemons/godebug/diff" "github.com/kylelemons/godebug/diff"
) )
func prettyPrint(i interface{}) string {
s, _ := json.MarshalIndent(i, "", "\t")
return string(s)
}
func TestNewCompareConfig(t *testing.T) { func TestNewCompareConfig(t *testing.T) {
type args struct { type args struct {
origin string origin string

View File

@ -8,6 +8,7 @@ package diff2
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"strings"
"github.com/StackExchange/dnscontrol/v3/models" "github.com/StackExchange/dnscontrol/v3/models"
) )
@ -96,18 +97,7 @@ General instructions:
// //
// Examples include: // Examples include:
func ByRecordSet(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) { func ByRecordSet(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) {
// dc stores the desired state. return byHelper(analyzeByRecordSet, existing, dc, compFunc)
desired := dc.Records
var err error
desired, err = handsoff(existing, desired, dc.Unmanaged, dc.UnmanagedUnsafe) // Handle UNMANAGED()
if err != nil {
return nil, err
}
cc := NewCompareConfig(dc.Name, existing, desired, compFunc)
instructions := analyzeByRecordSet(cc)
return processPurge(instructions, !dc.KeepUnknown), nil
} }
// ByLabel takes two lists of records (existing and desired) and // ByLabel takes two lists of records (existing and desired) and
@ -119,18 +109,7 @@ func ByRecordSet(existing models.Records, dc *models.DomainConfig, compFunc Comp
// //
// Examples include: // Examples include:
func ByLabel(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) { func ByLabel(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) {
// dc stores the desired state. return byHelper(analyzeByLabel, existing, dc, compFunc)
desired := dc.Records
var err error
desired, err = handsoff(existing, desired, dc.Unmanaged, dc.UnmanagedUnsafe) // Handle UNMANAGED()
if err != nil {
return nil, err
}
cc := NewCompareConfig(dc.Name, existing, desired, compFunc)
instructions := analyzeByLabel(cc)
return processPurge(instructions, !dc.KeepUnknown), nil
} }
// ByRecord takes two lists of records (existing and desired) and // ByRecord takes two lists of records (existing and desired) and
@ -146,61 +125,82 @@ func ByLabel(existing models.Records, dc *models.DomainConfig, compFunc Comparab
// //
// Examples include: INWX // Examples include: INWX
func ByRecord(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) { func ByRecord(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) {
// dc stores the desired state. return byHelper(analyzeByRecord, existing, dc, compFunc)
desired := dc.Records
var err error
desired, err = handsoff(existing, desired, dc.Unmanaged, dc.UnmanagedUnsafe) // Handle UNMANAGED()
if err != nil {
return nil, err
}
cc := NewCompareConfig(dc.Name, existing, desired, compFunc)
instructions := analyzeByRecord(cc)
return processPurge(instructions, !dc.KeepUnknown), nil
} }
// ByZone takes two lists of records (existing and desired) and // ByZone takes two lists of records (existing and desired) and
// returns text one would output to users describing the change. // 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 // Use this with DNS providers whose API updates the entire zone at a
// time. That is, to make any change (1 record or many) the entire DNS // time. That is, to make any change (even just 1 record) the entire DNS
// zone is uploaded. // zone is uploaded.
// //
// The user should see a list of changes as if individual records were // The user should see a list of changes as if individual records were updated.
// updated.
// //
// The caller of this function should: // Example usage:
// //
// changed, msgs := diff2.ByZone(existing, desired, origin, nil // msgs, changes, err := diff2.ByZone(foundRecords, dc, nil)
// fmt.Sprintf("CREATING ZONEFILE FOR THE FIRST TIME: dir/example.com.zone")) // if err != nil {
// if changed { // return nil, err
// // output msgs // }
// // generate the zone using the "desired" records // if changes {
// } // // Generate a "correction" that uploads the entire zone.
// // (dc.Records are the new records for the zone).
// }
// //
// Example providers include: BIND // Example providers include: BIND
func ByZone(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) ([]string, bool, error) { func ByZone(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) ([]string, bool, error) {
// dc stores the desired state.
if len(existing) == 0 { if len(existing) == 0 {
// Nothing previously existed. No need to output a list of individual changes. // Nothing previously existed. No need to output a list of individual changes.
return nil, true, nil return nil, true, nil
} }
desired := dc.Records // Only return the messages.
var err error instructions, err := byHelper(analyzeByRecord, existing, dc, compFunc)
desired, err = handsoff(existing, desired, dc.Unmanaged, dc.UnmanagedUnsafe) // Handle UNMANAGED() return justMsgs(instructions), len(instructions) != 0, err
}
//
// byHelper does 90% of the work for the By*() calls.
func byHelper(fn func(cc *CompareConfig) ChangeList, existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) {
// Process NO_PURGE/ENSURE_ABSENT and UNMANAGED/IGNORE_*.
desired, msgs, err := handsoff(
dc.Name,
existing, dc.Records, dc.EnsureAbsent,
dc.Unmanaged,
dc.UnmanagedUnsafe,
dc.KeepUnknown,
)
if err != nil { if err != nil {
return nil, false, err return nil, err
} }
// Regroup existing/desiredd for easy comparison:
cc := NewCompareConfig(dc.Name, existing, desired, compFunc) cc := NewCompareConfig(dc.Name, existing, desired, compFunc)
instructions := analyzeByRecord(cc)
instructions = processPurge(instructions, !dc.KeepUnknown) // Analyze and generate the instructions:
return justMsgs(instructions), len(instructions) != 0, nil instructions := 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 instructions, nil
} }
// Stringify the datastructures for easier debugging
func (c Change) String() string { func (c Change) String() string {
var buf bytes.Buffer var buf bytes.Buffer
b := &buf b := &buf

View File

@ -18,7 +18,7 @@ func groupbyRSet(recs models.Records, origin string) []recset {
// Sort the NameFQDN to a consistent order. The actual sort methodology // Sort the NameFQDN to a consistent order. The actual sort methodology
// doesn't matter as long as equal values are adjacent. // doesn't matter as long as equal values are adjacent.
// Use the PrettySort ordering so that the records are in a nice order. // Use the PrettySort ordering so that the records are extra pretty.
pretty := prettyzone.PrettySort(recs, origin, 0, nil) pretty := prettyzone.PrettySort(recs, origin, 0, nil)
recs = pretty.Records recs = pretty.Records

251
pkg/diff2/handsoff.go Normal file
View File

@ -0,0 +1,251 @@
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, IGNORE_*, and UNMANAGED features.
import (
"fmt"
"strings"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/gobwas/glob"
)
/*
# How do NO_PURGE, IGNORE_*, ENSURE_ABSENT and friends 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 ASSURE_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.
* UNMANAGED(labelglob, typelist, targetglob):
* "If an existing record matches this pattern, don't touch it!""
* IGNORE_NAME(foo, bar) is the same as UNMANAGED(foo, bar, "*")
* IGNORE_TARGET(foo) is the same as UNMANAGED("*", "*", 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 UNMANAGED() statement with a targetglob that matches
the external system's target values.
* ASSURE_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/UNMANAGED/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!
This is different than in the old system (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:
UNMANAGED is implemented as:
* Take the list of existing records. If any match one of the UNMANAGED 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_UNMANAGED_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.
* "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_UNMANAGED_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_*/UNMANAGED/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
}
// Process UNMANAGE/IGNORE_* and NO_PURGE features:
ignorable, foreign := processIgnoreAndNoPurge(domain, existing, desired, absences, unmanagedConfigs, noPurge)
if len(foreign) != 0 {
msgs = append(msgs, fmt.Sprintf("INFO: %d records not being deleted because of NO_PURGE:", len(foreign)))
for _, r := range foreign {
msgs = append(msgs, fmt.Sprintf(" %s. %s %s", r.GetLabelFQDN(), r.Type, r.GetTargetRFC1035Quoted()))
}
}
if len(ignorable) != 0 {
msgs = append(msgs, fmt.Sprintf("INFO: %d records not being deleted because of IGNORE*():", len(ignorable)))
for _, r := range ignorable {
msgs = append(msgs, fmt.Sprintf(" %s %s %s", r.GetLabelFQDN(), r.Type, r.GetTargetRFC1035Quoted()))
}
}
// Check for invalid use of IGNORE_*.
conflicts := findConflicts(unmanagedConfigs, desired)
if len(conflicts) != 0 {
msgs = append(msgs, fmt.Sprintf("INFO: %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.GetTargetRFC1035Quoted()))
}
if unmanagedSafely {
return nil, nil, fmt.Errorf(strings.Join(msgs, "\n") +
"ERROR: Unsafe to continue. Add DISABLE_UNMANAGED_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
}
// processIgnoreAndNoPurge processes the IGNORE_*()/UNMANAGED() and NO_PURGE/ENSURE_ABSENT_REC() 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 {
if matchAll(unmanagedConfigs, rec) {
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 matchAll(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
}
// matchAll returns true if rec matches any of the uconfigs.
func matchAll(uconfigs []*models.UnmanagedConfig, rec *models.RecordConfig) bool {
for _, uc := range uconfigs {
if matchLabel(uc.LabelGlob, rec.GetLabel()) &&
matchType(uc.RTypeMap, rec.Type) &&
matchTarget(uc.TargetGlob, rec.GetLabel()) {
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)
}

237
pkg/diff2/handsoff_test.go Normal file
View File

@ -0,0 +1,237 @@
package diff2
import (
"fmt"
"strings"
"testing"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/StackExchange/dnscontrol/v3/pkg/js"
"github.com/miekg/dns"
testifyrequire "github.com/stretchr/testify/require"
)
// parseZoneContents is copied verbatium from providers/bind/bindProvider.go
// because import cycles and... tests shouldn't depend on huge modules.
func parseZoneContents(content string, zoneName string, zonefileName string) (models.Records, error) {
zp := dns.NewZoneParser(strings.NewReader(content), zoneName, zonefileName)
foundRecords := models.Records{}
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
rec, err := models.RRtoRC(rr, zoneName)
if err != nil {
return nil, err
}
foundRecords = append(foundRecords, &rec)
}
if err := zp.Err(); err != nil {
return nil, fmt.Errorf("error while parsing '%v': %w", zonefileName, err)
}
return foundRecords, nil
}
func showRecs(recs models.Records) string {
result := ""
for _, rec := range recs {
result += (rec.GetLabel() +
" " + rec.Type +
" " + rec.GetTargetRFC1035Quoted() +
"\n")
}
return result
}
func handsoffHelper(t *testing.T, existingZone, desiredJs string, noPurge bool, resultWanted string) {
t.Helper()
existing, err := parseZoneContents(existingZone, "f.com", "no_file_name")
if err != nil {
panic(err)
}
//fmt.Printf("DEBUG: existing=%s\n", showRecs(existing))
dnsconfig, err := js.ExecuteJavascriptString([]byte(desiredJs), false, nil)
if err != nil {
panic(err)
}
dc := dnsconfig.FindDomain("f.com")
desired := dc.Records
absences := dc.EnsureAbsent
unmanagedConfigs := dc.Unmanaged
// BUG(tlim): For some reason ExecuteJavascriptString() isn't setting the NameFQDN on records.
// This fixes up the records. It is a crass workaround. We should find the real
// cause and fix it.
for i, j := range desired {
desired[i].SetLabel(j.GetLabel(), "f.com")
}
for i, j := range absences {
absences[i].SetLabel(j.GetLabel(), "f.com")
}
ignored, purged := processIgnoreAndNoPurge(
"f.com",
existing, desired,
absences,
unmanagedConfigs,
noPurge,
)
ignoredRecs := showRecs(ignored)
purgedRecs := showRecs(purged)
resultActual := "IGNORED:\n" + ignoredRecs + "FOREIGN:\n" + purgedRecs
resultWanted = strings.TrimSpace(resultWanted) + "\n"
resultActual = strings.TrimSpace(resultActual) + "\n"
existingTxt := showRecs(existing)
desiredTxt := showRecs(desired)
debugTxt := "EXISTING:\n" + existingTxt + "DESIRED:\n" + desiredTxt
if resultWanted != resultActual {
testifyrequire.Equal(t,
resultActual,
resultWanted,
"GOT =\n```\n%s```\nWANT=\n```%s```\nINPUTS=\n```\n%s\n```\n",
resultActual,
resultWanted,
debugTxt)
}
}
func Test_purge_empty(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
{})
`
handsoffHelper(t, existingZone, desiredJs, false, `
IGNORED:
FOREIGN:
`)
}
func Test_purge_1(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
foo3 IN A 2.2.2.2
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
{})
`
handsoffHelper(t, existingZone, desiredJs, false, `
IGNORED:
FOREIGN:
`)
}
func Test_nopurge_1(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
foo3 IN A 3.3.3.3
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
{})
`
handsoffHelper(t, existingZone, desiredJs, true, `
IGNORED:
FOREIGN:
foo3 A 3.3.3.3
`)
}
func Test_absent_1(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
foo3 IN A 3.3.3.3
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
A("foo3", "3.3.3.3", ENSURE_ABSENT_REC()),
{})
`
handsoffHelper(t, existingZone, desiredJs, true, `
IGNORED:
FOREIGN:
`)
}
func Test_ignore_lab(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
foo3 IN A 3.3.3.3
foo3 IN MX 10 mymx.example.com.
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
IGNORE_NAME("foo3"),
{})
`
handsoffHelper(t, existingZone, desiredJs, true, `
IGNORED:
foo3 A 3.3.3.3
foo3 MX 10 mymx.example.com.
FOREIGN:
`)
}
func Test_ignore_labAndType(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
foo3 IN A 3.3.3.3
foo3 IN MX 10 mymx.example.com.
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
A("foo3", "3.3.3.3"),
IGNORE_NAME("foo3", "MX"),
{})
`
handsoffHelper(t, existingZone, desiredJs, true, `
IGNORED:
foo3 MX 10 mymx.example.com.
FOREIGN:
`)
}
func Test_ignore_target(t *testing.T) {
existingZone := `
foo1 IN A 1.1.1.1
foo2 IN A 2.2.2.2
_2222222222222222.cr IN CNAME _333333.nnn.acm-validations.aws.
`
desiredJs := `
D("f.com", "none",
A("foo1", "1.1.1.1"),
A("foo2", "2.2.2.2"),
MX("foo3", 10, "mymx.example.com."),
IGNORE_TARGET('**.acm-validations.aws.', 'CNAME'),
{})
`
handsoffHelper(t, existingZone, desiredJs, true, `
IGNORED:
FOREIGN:
_2222222222222222.cr CNAME _333333.nnn.acm-validations.aws.
`)
}

View File

@ -1,40 +0,0 @@
package diff2
import "strings"
func processPurge(instructions ChangeList, nopurge bool) ChangeList {
if nopurge {
return instructions
}
// TODO(tlim): This can probably be done without allocations but it
// works and I won't want to prematurely optimize.
var msgs []string
newinstructions := make(ChangeList, 0, len(instructions))
for _, j := range instructions {
if j.Type == DELETE {
msgs = append(msgs, j.Msgs...)
continue
}
newinstructions = append(newinstructions, j)
}
// Report what would have been purged
if len(msgs) != 0 {
for i := range msgs {
msgs[i] = "NO_PURGE: Skipping " + msgs[i]
}
msgs = append([]string{"NO_PURGE Activated! Skipping these actions:"}, msgs...)
newinstructions = append(newinstructions, Change{
Type: REPORT,
Msgs: msgs,
MsgsJoined: strings.Join(msgs, "\n"),
})
}
return newinstructions
}

View File

@ -1,129 +0,0 @@
package diff2
import (
"fmt"
"strings"
"github.com/gobwas/glob"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/StackExchange/dnscontrol/v3/pkg/printer"
)
func handsoff(
existing, desired models.Records,
unmanaged []*models.UnmanagedConfig,
beSafe bool,
) (models.Records, error) {
// What foreign items should we ignore?
foreign, err := manyQueries(existing, unmanaged)
if err != nil {
return nil, err
}
if len(foreign) != 0 {
printer.Printf("INFO: Foreign records being ignored: (%d)\n", len(foreign))
for i, r := range foreign {
printer.Printf("- % 4d: %s %s %s\n", i, r.GetLabelFQDN(), r.Type, r.GetTargetRFC1035Quoted())
}
}
// What desired items might conflict?
conflicts, err := manyQueries(desired, unmanaged)
if err != nil {
return nil, err
}
if len(conflicts) != 0 {
level := "WARN"
if beSafe {
level = "ERROR"
}
printer.Printf("%s: dnsconfig.js records that overlap MANAGED: (%d)\n", level, len(conflicts))
for i, r := range conflicts {
printer.Printf("- % 4d: %s %s %s\n", i, r.GetLabelFQDN(), r.Type, r.GetTargetRFC1035Quoted())
}
if beSafe {
return nil, fmt.Errorf("ERROR: Unsafe to continue. Add DISABLE_UNMANAGED_SAFETY_CHECK to D() to override")
}
}
// Add the foreign items to the desired list.
// (Rather than literally ignoring them, we just add them to the desired list
// and all the diffing algorithms become more simple.)
desired = append(desired, foreign...)
return desired, nil
}
func manyQueries(rcs models.Records, queries []*models.UnmanagedConfig) (result models.Records, err error) {
for _, q := range queries {
lab := q.Label
if lab == "" {
lab = "*"
}
glabel, err := glob.Compile(lab)
if err != nil {
return nil, err
}
targ := q.Target
if targ == "" {
targ = "*"
}
gtarget, err := glob.Compile(targ)
if err != nil {
return nil, err
}
hasRType := compileTypeGlob(q.RType)
for _, rc := range rcs {
if match(rc, glabel, gtarget, hasRType) {
result = append(result, rc)
}
}
}
return result, nil
}
func compileTypeGlob(g string) map[string]bool {
m := map[string]bool{}
for _, j := range strings.Split(g, ",") {
m[strings.TrimSpace(j)] = true
}
return m
}
func match(rc *models.RecordConfig, glabel, gtarget glob.Glob, hasRType map[string]bool) bool {
//printer.Printf("DEBUG: match(%v, %v, %v, %v)\n", rc.NameFQDN, glabel, gtarget, hasRType)
// _ = glabel.Match(rc.NameFQDN)
// _ = matchType(rc.Type, hasRType)
// x := rc.GetTargetField()
// _ = gtarget.Match(x)
if !glabel.Match(rc.NameFQDN) {
//printer.Printf("DEBUG: REJECTED LABEL: %s:%v\n", rc.NameFQDN, glabel)
return false
} else if !matchType(rc.Type, hasRType) {
//printer.Printf("DEBUG: REJECTED TYPE: %s:%v\n", rc.Type, hasRType)
return false
} else if gtarget == nil {
return true
} else if !gtarget.Match(rc.GetTargetField()) {
//printer.Printf("DEBUG: REJECTED TARGET: %v:%v\n", rc.GetTargetField(), gtarget)
return false
}
return true
}
func matchType(s string, hasRType map[string]bool) bool {
//printer.Printf("DEBUG: matchType map=%v\n", hasRType)
if len(hasRType) == 0 {
return true
}
_, ok := hasRType[s]
return ok
}

View File

@ -1,147 +0,0 @@
package diff2
import (
"testing"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/gobwas/glob"
)
var rmapNil map[string]bool
var rmapAMX = map[string]bool{
"A": true,
"MX": true,
}
var rmapCNAME = map[string]bool{
"CNAME": true,
}
func Test_match(t *testing.T) {
testRecLammaA1234 := makeRec("lamma", "A", "1.2.3.4")
type args struct {
rc *models.RecordConfig
glabel glob.Glob
gtarget glob.Glob
hasRType map[string]bool
}
tests := []struct {
name string
args args
want bool
}{
{
name: "match3",
args: args{
rc: testRecLammaA1234,
glabel: glob.MustCompile("lam*"),
hasRType: rmapAMX,
gtarget: glob.MustCompile("1.2.3.*"),
},
want: true,
},
{
name: "match2",
args: args{
rc: testRecLammaA1234,
glabel: glob.MustCompile("lam*"),
hasRType: rmapAMX,
gtarget: nil,
},
want: true,
},
{
name: "match1",
args: args{
rc: testRecLammaA1234,
glabel: glob.MustCompile("lam*"),
hasRType: rmapNil,
gtarget: nil,
},
want: true,
},
{
name: "reject1",
args: args{
rc: testRecLammaA1234,
glabel: glob.MustCompile("yyyy"),
hasRType: rmapAMX,
gtarget: glob.MustCompile("1.2.3.*"),
},
want: false,
},
{
name: "reject2",
args: args{
rc: testRecLammaA1234,
glabel: glob.MustCompile("lam*"),
hasRType: rmapCNAME,
gtarget: glob.MustCompile("1.2.3.*"),
},
want: false,
},
{
name: "reject3",
args: args{
rc: testRecLammaA1234,
glabel: glob.MustCompile("lam*"),
hasRType: rmapAMX,
gtarget: glob.MustCompile("zzzzz"),
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := match(tt.args.rc, tt.args.glabel, tt.args.gtarget, tt.args.hasRType); got != tt.want {
t.Errorf("match() = %v, want %v", got, tt.want)
}
})
}
}
func Test_matchType(t *testing.T) {
type args struct {
s string
hasRType map[string]bool
}
tests := []struct {
name string
args args
want bool
}{
{
name: "matchCNAME",
args: args{"CNAME", rmapCNAME},
want: true,
},
{
name: "rejectCNAME",
args: args{"MX", rmapCNAME},
want: false,
},
{
name: "matchNIL",
args: args{"CNAME", rmapNil},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := matchType(tt.args.s, tt.args.hasRType); got != tt.want {
t.Errorf("matchType() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -104,6 +104,7 @@ function newDomain(name, registrar) {
registrar: registrar, registrar: registrar,
meta: {}, meta: {},
records: [], records: [],
recordsabsent: [],
dnsProviders: {}, dnsProviders: {},
defaultTTL: 0, defaultTTL: 0,
nameservers: [], nameservers: [],
@ -614,7 +615,6 @@ function IGNORE_NAME(name, rTypes) {
d.unmanaged.push({ d.unmanaged.push({
label_pattern: name, label_pattern: name,
rType_pattern: rTypes, rType_pattern: rTypes,
target_pattern: '*',
}); });
}; };
} }
@ -632,7 +632,6 @@ function IGNORE_TARGET(target, rType) {
return function (d) { return function (d) {
d.ignored_targets.push({ pattern: target, type: rType }); d.ignored_targets.push({ pattern: target, type: rType });
d.unmanaged.push({ d.unmanaged.push({
label_pattern: '*',
rType_pattern: rType, rType_pattern: rType,
target_pattern: target, target_pattern: target,
}); });
@ -660,6 +659,22 @@ function NO_PURGE(d) {
d.KeepUnknown = true; d.KeepUnknown = true;
} }
// ENSURE_ABSENT_REC()
// Usage: A("foo", "1.2.3.4", ENSURE_ABSENT_REC())
function ENSURE_ABSENT_REC() {
return function (r) {
r.ensure_absent = true;
};
}
// ENSURE_ABSENT()
// Usage: ENSURE_ABSENT(A("foo", "1.2.3.4"))
// (BROKEN. COMMENTED OUT UNTIL IT IS FIXED.)
// function ENSURE_ABSENT(r) {
// //console.log(r);
// return r;
// }
// AUTODNSSEC // AUTODNSSEC
// Permitted values are: // Permitted values are:
// "" Do not modify the setting (the default) // "" Do not modify the setting (the default)
@ -678,15 +693,6 @@ function AUTODNSSEC(d) {
} }
function UNMANAGED(label_pattern, rType_pattern, target_pattern) { function UNMANAGED(label_pattern, rType_pattern, target_pattern) {
if (rType_pattern === undefined) {
rType_pattern = '*';
}
if (rType_pattern === "") {
rType_pattern = '*';
}
if (target_pattern === undefined) {
target_pattern = '*';
}
return function (d) { return function (d) {
d.unmanaged.push({ d.unmanaged.push({
label_pattern: label_pattern, label_pattern: label_pattern,
@ -835,7 +841,15 @@ function recordBuilder(type, opts) {
} }
} }
d.records.push(record); // Now we finally have the record. If it is a normal record, we add
// it to "records". If it is an ENSURE_ABSENT record, we add it to
// the ensure_absent list.
if (record.ensure_absent) {
d.recordsabsent.push(record);
} else {
d.records.push(record);
}
return record; return record;
}; };
}; };

View File

@ -45,6 +45,11 @@ func ExecuteJavascript(file string, devMode bool, variables map[string]string) (
// Record the directory path leading up to this file. // Record the directory path leading up to this file.
currentDirectory = filepath.Dir(file) currentDirectory = filepath.Dir(file)
return ExecuteJavascriptString(script, devMode, variables)
}
func ExecuteJavascriptString(script []byte, devMode bool, variables map[string]string) (*models.DNSConfig, error) {
vm := otto.New() vm := otto.New()
l := loop.New(vm) l := loop.New(vm)

View File

@ -46,45 +46,37 @@
"unmanaged": [ "unmanaged": [
{ {
"label_pattern": "testignore", "label_pattern": "testignore",
"rType_pattern": "*", "rType_pattern": "*"
"target_pattern": "*"
}, },
{ {
"label_pattern": "testignore2", "label_pattern": "testignore2",
"rType_pattern": "A", "rType_pattern": "A"
"target_pattern": "*"
}, },
{ {
"label_pattern": "testignore3", "label_pattern": "testignore3",
"rType_pattern": "A, CNAME, TXT", "rType_pattern": "A, CNAME, TXT"
"target_pattern": "*"
}, },
{ {
"label_pattern": "testignore4", "label_pattern": "testignore4",
"rType_pattern": "*", "rType_pattern": "*"
"target_pattern": "*"
}, },
{ {
"label_pattern": "*",
"rType_pattern": "CNAME", "rType_pattern": "CNAME",
"target_pattern": "testtarget" "target_pattern": "testtarget"
}, },
{ {
"label_pattern": "legacyignore", "label_pattern": "legacyignore",
"rType_pattern": "*", "rType_pattern": "*"
"target_pattern": "*"
}, },
{ {
"label_pattern": "@", "label_pattern": "@",
"rType_pattern": "*", "rType_pattern": "*"
"target_pattern": "*"
}, },
{ {
"label_pattern": "*",
"rType_pattern": "CNAME", "rType_pattern": "CNAME",
"target_pattern": "@" "target_pattern": "@"
} }
] ]
} }
] ]
} }

View File

@ -16,10 +16,9 @@
"unmanaged": [ "unmanaged": [
{ {
"label_pattern": "\\*.testignore", "label_pattern": "\\*.testignore",
"rType_pattern": "*", "rType_pattern": "*"
"target_pattern": "*"
} }
] ]
} }
] ]
} }

View File

@ -1,6 +1,9 @@
D("foo.com", "none" D("foo.com", "none"
, UNMANAGED("one") , UNMANAGED("", "", "targetGlob1")
, UNMANAGED("two", "A, CNAME") , UNMANAGED("", "CNAME", "")
, UNMANAGED("three", "TXT", "findme") , UNMANAGED("", "A", "targetGlob3")
, UNMANAGED("notype", "", "targglob") , UNMANAGED("lab4")
, UNMANAGED("notype", "", "targetGlob5")
, UNMANAGED("lab6", "A, CNAME")
, UNMANAGED("lab7", "TXT", "targetGlob7")
); );

View File

@ -1,34 +1,40 @@
{ {
"registrars": [],
"dns_providers": [], "dns_providers": [],
"domains": [ "domains": [
{ {
"name": "foo.com",
"registrar": "none",
"dnsProviders": {}, "dnsProviders": {},
"name": "foo.com",
"records": [], "records": [],
"registrar": "none",
"unmanaged": [ "unmanaged": [
{ {
"label_pattern": "one", "target_pattern": "targetGlob1"
"rType_pattern": "*",
"target_pattern": "*"
}, },
{ {
"label_pattern": "two", "rType_pattern": "CNAME"
"rType_pattern": "A, CNAME",
"target_pattern": "*"
}, },
{ {
"label_pattern": "three", "rType_pattern": "A",
"rType_pattern": "TXT", "target_pattern": "targetGlob3"
"target_pattern": "findme" },
{
"label_pattern": "lab4"
}, },
{ {
"label_pattern": "notype", "label_pattern": "notype",
"rType_pattern": "*", "target_pattern": "targetGlob5"
"target_pattern": "targglob" },
{
"label_pattern": "lab6",
"rType_pattern": "A, CNAME"
},
{
"label_pattern": "lab7",
"rType_pattern": "TXT",
"target_pattern": "targetGlob7"
} }
] ]
} }
] ],
} "registrars": []
}

View File

@ -0,0 +1,5 @@
D("example.com", "none",
A("normal", "1.1.1.1"),
A("helper", "2.2.2.2", ENSURE_ABSENT_REC()),
//ENSURE_ABSENT(A("wrapped", "3.3.3.3")),
{});

View File

@ -0,0 +1,25 @@
{
"dns_providers": [],
"domains": [
{
"dnsProviders": {},
"name": "example.com",
"records": [
{
"name": "normal",
"target": "1.1.1.1",
"type": "A"
}
],
"recordsabsent": [
{
"name": "helper",
"target": "2.2.2.2",
"type": "A"
}
],
"registrar": "none"
}
],
"registrars": []
}

29
pkg/recorddb/recorddb.go Normal file
View File

@ -0,0 +1,29 @@
package recorddb
import "github.com/StackExchange/dnscontrol/v3/models"
// Functions that make it easier to deal with
// a group of records.
//
type RecordDB = struct {
labelAndTypeMap map[models.RecordKey]struct{}
}
func NewFromRecords(recs models.Records) *RecordDB {
result := &RecordDB{}
result.labelAndTypeMap = make(map[models.RecordKey]struct{}, len(recs))
for _, rec := range recs {
result.labelAndTypeMap[rec.Key()] = struct{}{}
}
return result
}
// ContainsLT returns true if recdb contains rec. Matching is done
// on the record's label and type (i.e. the RecordKey)
//func (recdb RecordDB) ContainsLT(rec *models.RecordConfig) bool {
// _, ok := recdb.labelAndTypeMap[rec.Key()]
// return ok
//}

View File

@ -153,7 +153,6 @@ func (c *bindProvider) ListZones() ([]string, error) {
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. // GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
func (c *bindProvider) GetZoneRecords(domain string) (models.Records, error) { func (c *bindProvider) GetZoneRecords(domain string) (models.Records, error) {
foundRecords := models.Records{}
if _, err := os.Stat(c.directory); os.IsNotExist(err) { if _, err := os.Stat(c.directory); os.IsNotExist(err) {
printer.Printf("\nWARNING: BIND directory %q does not exist!\n", c.directory) printer.Printf("\nWARNING: BIND directory %q does not exist!\n", c.directory)
@ -177,10 +176,17 @@ func (c *bindProvider) GetZoneRecords(domain string) (models.Records, error) {
} }
c.zoneFileFound = true c.zoneFileFound = true
zp := dns.NewZoneParser(strings.NewReader(string(content)), domain, c.zonefile) zonefileName := c.zonefile
return ParseZoneContents(string(content), domain, zonefileName)
}
func ParseZoneContents(content string, zoneName string, zonefileName string) (models.Records, error) {
zp := dns.NewZoneParser(strings.NewReader(content), zoneName, zonefileName)
foundRecords := models.Records{}
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() { for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
rec, err := models.RRtoRC(rr, domain) rec, err := models.RRtoRC(rr, zoneName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -188,7 +194,7 @@ func (c *bindProvider) GetZoneRecords(domain string) (models.Records, error) {
} }
if err := zp.Err(); err != nil { if err := zp.Err(); err != nil {
return nil, fmt.Errorf("error while parsing '%v': %w", c.zonefile, err) return nil, fmt.Errorf("error while parsing '%v': %w", zonefileName, err)
} }
return foundRecords, nil return foundRecords, nil
} }

View File

@ -489,7 +489,7 @@ func (r *route53Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*mode
switch inst.Type { switch inst.Type {
case diff2.REPORT: case diff2.REPORT:
corrections = append(corrections, &models.Correction{Msg: inst.MsgsJoined}) chg = r53Types.Change{}
case diff2.CREATE: case diff2.CREATE:
fallthrough fallthrough
@ -586,8 +586,9 @@ func (r *route53Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*mode
func reorderInstructions(changes diff2.ChangeList) diff2.ChangeList { func reorderInstructions(changes diff2.ChangeList) diff2.ChangeList {
var main, tail diff2.ChangeList var main, tail diff2.ChangeList
for _, change := range changes { for _, change := range changes {
//if change.Key.Type == "R53_ALIAS" { // Reports should be early in the list.
if strings.HasPrefix(change.Key.Type, "R53_ALIAS_") { // R53_ALIAS_ records should go to the tail.
if change.Type != diff2.REPORT && strings.HasPrefix(change.Key.Type, "R53_ALIAS_") {
tail = append(tail, change) tail = append(tail, change)
} else { } else {
main = append(main, change) main = append(main, change)
@ -889,6 +890,11 @@ func (b *changeBatcher) Next() bool {
c := &b.changes[end] c := &b.changes[end]
// Check that we won't exceed 1000 ResourceRecords in the request. // Check that we won't exceed 1000 ResourceRecords in the request.
if c.ResourceRecordSet == nil {
end++
continue
}
rrsetSize := len(c.ResourceRecordSet.ResourceRecords) rrsetSize := len(c.ResourceRecordSet.ResourceRecords)
if c.Action == r53Types.ChangeActionUpsert { if c.Action == r53Types.ChangeActionUpsert {
// "When the value of the Action element is UPSERT, each ResourceRecord element is counted twice." // "When the value of the Action element is UPSERT, each ResourceRecord element is counted twice."