From fc3a217dc1da17ad2d8d36640393cfdeb546ae11 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Sun, 19 Feb 2023 12:33:08 -0500 Subject: [PATCH] Bugfixed: NO_PURGE now works on all diff2 providers (#2084) --- integrationTest/integration_test.go | 1 + models/domain.go | 16 +- models/record.go | 3 + models/recorddb.go | 30 +++ models/unmanaged.go | 20 ++ pkg/diff/diff2compat.go | 5 +- pkg/diff2/compareconfig.go | 27 +- pkg/diff2/compareconfig_test.go | 6 - pkg/diff2/diff2.go | 110 ++++---- pkg/diff2/groupsort.go | 2 +- pkg/diff2/handsoff.go | 251 ++++++++++++++++++ pkg/diff2/handsoff_test.go | 237 +++++++++++++++++ pkg/diff2/nopurge.go | 40 --- pkg/diff2/unmanaged.go | 129 --------- pkg/diff2/unmanaged_test.go | 147 ---------- pkg/js/helpers.js | 38 ++- pkg/js/js.go | 5 + pkg/js/parse_tests/005-ignored-records.json | 22 +- .../parse_tests/023-ignored-glob-records.json | 5 +- pkg/js/parse_tests/042-unmanaged.js | 11 +- pkg/js/parse_tests/042-unmanaged.json | 38 +-- pkg/js/parse_tests/044-ensureabsent.js | 5 + pkg/js/parse_tests/044-ensureabsent.json | 25 ++ pkg/recorddb/recorddb.go | 29 ++ providers/bind/bindProvider.go | 14 +- providers/route53/route53Provider.go | 12 +- 26 files changed, 768 insertions(+), 460 deletions(-) create mode 100644 models/recorddb.go create mode 100644 models/unmanaged.go create mode 100644 pkg/diff2/handsoff.go create mode 100644 pkg/diff2/handsoff_test.go delete mode 100644 pkg/diff2/nopurge.go delete mode 100644 pkg/diff2/unmanaged.go delete mode 100644 pkg/diff2/unmanaged_test.go create mode 100644 pkg/js/parse_tests/044-ensureabsent.js create mode 100644 pkg/js/parse_tests/044-ensureabsent.json create mode 100644 pkg/recorddb/recorddb.go diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index 59b34fdc3..324d9aa92 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -750,6 +750,7 @@ func makeTests(t *testing.T) []*TestGroup { // This is a strange one. It adds a new record to an existing // label but the pre-existing label has its TTL change. 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("Add at same label, new ttl", ttl(a("www", "5.6.7.8"), 700), ttl(a("www", "1.2.3.4"), 700)), ), diff --git a/models/domain.go b/models/domain.go index 8c1b4cd7a..fb5220bdc 100644 --- a/models/domain.go +++ b/models/domain.go @@ -19,11 +19,13 @@ type DomainConfig struct { Records Records `json:"records"` 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"` IgnoredTargets []*IgnoreTarget `json:"ignored_targets,omitempty"` - Unmanaged []*UnmanagedConfig `json:"unmanaged,omitempty"` - UnmanagedUnsafe bool `json:"unmanaged_disable_safety_check,omitempty"` + Unmanaged []*UnmanagedConfig `json:"unmanaged,omitempty"` // UNMANAGED() + UnmanagedUnsafe bool `json:"unmanaged_disable_safety_check,omitempty"` // DISABLE_UNMANAGED_SAFETY_CHECK AutoDNSSEC string `json:"auto_dnssec,omitempty"` // "", "on", "off" //DNSSEC bool `json:"dnssec,omitempty"` @@ -36,14 +38,6 @@ type DomainConfig struct { 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. func (dc *DomainConfig) Copy() (*DomainConfig, error) { newDc := &DomainConfig{} diff --git a/models/record.go b/models/record.go index e89700701..d780fd0c1 100644 --- a/models/record.go +++ b/models/record.go @@ -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. R53Alias map[string]string `json:"r53_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 // the fields, please let us know! }{} diff --git a/models/recorddb.go b/models/recorddb.go new file mode 100644 index 000000000..916301684 --- /dev/null +++ b/models/recorddb.go @@ -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 +} diff --git a/models/unmanaged.go b/models/unmanaged.go new file mode 100644 index 000000000..36880faa9 --- /dev/null +++ b/models/unmanaged.go @@ -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 +} diff --git a/pkg/diff/diff2compat.go b/pkg/diff/diff2compat.go index 258c36c98..fff0daa47 100644 --- a/pkg/diff/diff2compat.go +++ b/pkg/diff/diff2compat.go @@ -65,8 +65,9 @@ func (d *differCompat) IncrementalDiff(existing []*models.RecordConfig) (unchang cor := Correlation{d: d.OldDiffer} switch inst.Type { case diff2.REPORT: - // Sadly the NewCompat function doesn't have a way to do this. - // Purge reports are silently skipped. + // Sadly the NewCompat function doesn't have an equivalent. We + // just output the messages now. + fmt.Println(inst.MsgsJoined) case diff2.CREATE: cor.Desired = inst.New[0] create = append(create, cor) diff --git a/pkg/diff2/compareconfig.go b/pkg/diff2/compareconfig.go index 4ce45e55c..3cd93a51b 100644 --- a/pkg/diff2/compareconfig.go +++ b/pkg/diff2/compareconfig.go @@ -109,10 +109,8 @@ func NewCompareConfig(origin string, existing, desired models.Records, compFn Co func (cc *CompareConfig) VerifyCNAMEAssertions() { // 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 - // true. That said, a little paranoia is healthy. Those familiar - // with the Therac-25 accident will agree: - // https://hackaday.com/2015/10/26/killed-by-a-machine-the-therac-25/ + // something that can not happen. In my I've proved this to be + // true. That said, a little paranoia is healthy. // 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 @@ -141,15 +139,28 @@ func (cc *CompareConfig) VerifyCNAMEAssertions() { for _, ld := range cc.ldata { for j, td := range ld.tdata { + 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 { //fmt.Printf("DEBUG: cname in existing: index=%d\n", j) if j != 0 { panic("should not happen: (CNAME not in first position)") } } + if len(td.desiredTargets) != 0 { //fmt.Printf("DEBUG: cname in desired: index=%d\n", j) + //fmt.Printf("DEBUG: highest: index=%d\n", j) if j != highest(ld.tdata) { 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 { - //label := rec.NameFQDN - //rtype := rec.Type key := rec.Key() label := key.NameFQDN 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? var labelIdx int if _, ok := cc.labelMap[label]; !ok { - //fmt.Printf("DEBUG: I haven't see label=%v before. Adding.\n", label) cc.labelMap[label] = true cc.ldata = append(cc.ldata, &labelConfig{label: label}) labelIdx = highest(cc.ldata) } else { - // find label in cc.ldata: for k, v := range cc.ldata { if v.label == label { 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? - //key := rec.Key() if _, ok := cc.keyMap[key]; !ok { - //fmt.Printf("DEBUG: I haven't see key=%v before. Adding.\n", key) cc.keyMap[key] = true x := cc.ldata[labelIdx] - //fmt.Printf("DEBUG: appending rtype=%v\n", rtype) x.tdata = append(x.tdata, &rTypeConfig{rType: rtype}) } var rtIdx int @@ -256,7 +260,6 @@ func (cc *CompareConfig) addRecords(recs models.Records, storeInExisting bool) { break } } - //fmt.Printf("DEBUG: found rtype=%v at index %d\n", rtype, rtIdx) // Now it is safe to add/modify the records. diff --git a/pkg/diff2/compareconfig_test.go b/pkg/diff2/compareconfig_test.go index 6c807f751..54e3da9cc 100644 --- a/pkg/diff2/compareconfig_test.go +++ b/pkg/diff2/compareconfig_test.go @@ -1,7 +1,6 @@ package diff2 import ( - "encoding/json" "strings" "testing" @@ -9,11 +8,6 @@ import ( "github.com/kylelemons/godebug/diff" ) -func prettyPrint(i interface{}) string { - s, _ := json.MarshalIndent(i, "", "\t") - return string(s) -} - func TestNewCompareConfig(t *testing.T) { type args struct { origin string diff --git a/pkg/diff2/diff2.go b/pkg/diff2/diff2.go index 02fcf669f..382300490 100644 --- a/pkg/diff2/diff2.go +++ b/pkg/diff2/diff2.go @@ -8,6 +8,7 @@ package diff2 import ( "bytes" "fmt" + "strings" "github.com/StackExchange/dnscontrol/v3/models" ) @@ -96,18 +97,7 @@ General instructions: // // Examples include: func ByRecordSet(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) { - // dc stores the desired state. - - 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 + return byHelper(analyzeByRecordSet, existing, dc, compFunc) } // 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: func ByLabel(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) { - // dc stores the desired state. - - 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 + return byHelper(analyzeByLabel, existing, dc, compFunc) } // 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 func ByRecord(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) (ChangeList, error) { - // dc stores the desired state. - - 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 + return byHelper(analyzeByRecord, existing, dc, compFunc) } // 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 -// 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. // -// The user should see a list of changes as if individual records were -// updated. +// The user should see a list of changes as if individual records were updated. // -// The caller of this function should: +// Example usage: // -// changed, msgs := diff2.ByZone(existing, desired, origin, nil -// fmt.Sprintf("CREATING ZONEFILE FOR THE FIRST TIME: dir/example.com.zone")) -// if changed { -// // output msgs -// // generate the zone using the "desired" records -// } +// msgs, changes, err := diff2.ByZone(foundRecords, dc, nil) +// if err != nil { +// return nil, err +// } +// if changes { +// // Generate a "correction" that uploads the entire zone. +// // (dc.Records are the new records for the zone). +// } // // Example providers include: BIND func ByZone(existing models.Records, dc *models.DomainConfig, compFunc ComparableFunc) ([]string, bool, error) { - // dc stores the desired state. if len(existing) == 0 { // Nothing previously existed. No need to output a list of individual changes. return nil, true, nil } - desired := dc.Records - var err error - desired, err = handsoff(existing, desired, dc.Unmanaged, dc.UnmanagedUnsafe) // Handle UNMANAGED() + // Only return the messages. + instructions, err := byHelper(analyzeByRecord, existing, dc, compFunc) + 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 { - return nil, false, err + return nil, err } + // Regroup existing/desiredd for easy comparison: cc := NewCompareConfig(dc.Name, existing, desired, compFunc) - instructions := analyzeByRecord(cc) - instructions = processPurge(instructions, !dc.KeepUnknown) - return justMsgs(instructions), len(instructions) != 0, nil + + // Analyze and generate the instructions: + 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 { var buf bytes.Buffer b := &buf diff --git a/pkg/diff2/groupsort.go b/pkg/diff2/groupsort.go index 30dacbec0..ba9b1ed0b 100644 --- a/pkg/diff2/groupsort.go +++ b/pkg/diff2/groupsort.go @@ -18,7 +18,7 @@ func groupbyRSet(recs models.Records, origin string) []recset { // Sort the NameFQDN to a consistent order. The actual sort methodology // 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) recs = pretty.Records diff --git a/pkg/diff2/handsoff.go b/pkg/diff2/handsoff.go new file mode 100644 index 000000000..ebe1047d3 --- /dev/null +++ b/pkg/diff2/handsoff.go @@ -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) +} diff --git a/pkg/diff2/handsoff_test.go b/pkg/diff2/handsoff_test.go new file mode 100644 index 000000000..6d5b0fd19 --- /dev/null +++ b/pkg/diff2/handsoff_test.go @@ -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. + `) +} diff --git a/pkg/diff2/nopurge.go b/pkg/diff2/nopurge.go deleted file mode 100644 index cf8bfd05d..000000000 --- a/pkg/diff2/nopurge.go +++ /dev/null @@ -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 - -} diff --git a/pkg/diff2/unmanaged.go b/pkg/diff2/unmanaged.go deleted file mode 100644 index 5171752b2..000000000 --- a/pkg/diff2/unmanaged.go +++ /dev/null @@ -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 -} diff --git a/pkg/diff2/unmanaged_test.go b/pkg/diff2/unmanaged_test.go deleted file mode 100644 index 6d0fd38b9..000000000 --- a/pkg/diff2/unmanaged_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/pkg/js/helpers.js b/pkg/js/helpers.js index 46a16eefe..d937dfe5c 100644 --- a/pkg/js/helpers.js +++ b/pkg/js/helpers.js @@ -104,6 +104,7 @@ function newDomain(name, registrar) { registrar: registrar, meta: {}, records: [], + recordsabsent: [], dnsProviders: {}, defaultTTL: 0, nameservers: [], @@ -614,7 +615,6 @@ function IGNORE_NAME(name, rTypes) { d.unmanaged.push({ label_pattern: name, rType_pattern: rTypes, - target_pattern: '*', }); }; } @@ -632,7 +632,6 @@ function IGNORE_TARGET(target, rType) { return function (d) { d.ignored_targets.push({ pattern: target, type: rType }); d.unmanaged.push({ - label_pattern: '*', rType_pattern: rType, target_pattern: target, }); @@ -660,6 +659,22 @@ function NO_PURGE(d) { 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 // Permitted values are: // "" Do not modify the setting (the default) @@ -678,15 +693,6 @@ function AUTODNSSEC(d) { } 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) { d.unmanaged.push({ 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; }; }; diff --git a/pkg/js/js.go b/pkg/js/js.go index 676b111ad..910dc4431 100644 --- a/pkg/js/js.go +++ b/pkg/js/js.go @@ -45,6 +45,11 @@ func ExecuteJavascript(file string, devMode bool, variables map[string]string) ( // Record the directory path leading up to this 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() l := loop.New(vm) diff --git a/pkg/js/parse_tests/005-ignored-records.json b/pkg/js/parse_tests/005-ignored-records.json index 2fd8a0c95..618f5b09f 100644 --- a/pkg/js/parse_tests/005-ignored-records.json +++ b/pkg/js/parse_tests/005-ignored-records.json @@ -46,45 +46,37 @@ "unmanaged": [ { "label_pattern": "testignore", - "rType_pattern": "*", - "target_pattern": "*" + "rType_pattern": "*" }, { "label_pattern": "testignore2", - "rType_pattern": "A", - "target_pattern": "*" + "rType_pattern": "A" }, { "label_pattern": "testignore3", - "rType_pattern": "A, CNAME, TXT", - "target_pattern": "*" + "rType_pattern": "A, CNAME, TXT" }, { "label_pattern": "testignore4", - "rType_pattern": "*", - "target_pattern": "*" + "rType_pattern": "*" }, { - "label_pattern": "*", "rType_pattern": "CNAME", "target_pattern": "testtarget" }, { "label_pattern": "legacyignore", - "rType_pattern": "*", - "target_pattern": "*" + "rType_pattern": "*" }, { "label_pattern": "@", - "rType_pattern": "*", - "target_pattern": "*" + "rType_pattern": "*" }, { - "label_pattern": "*", "rType_pattern": "CNAME", "target_pattern": "@" } ] } ] -} \ No newline at end of file +} diff --git a/pkg/js/parse_tests/023-ignored-glob-records.json b/pkg/js/parse_tests/023-ignored-glob-records.json index 8ea43afdd..97cce3ecf 100644 --- a/pkg/js/parse_tests/023-ignored-glob-records.json +++ b/pkg/js/parse_tests/023-ignored-glob-records.json @@ -16,10 +16,9 @@ "unmanaged": [ { "label_pattern": "\\*.testignore", - "rType_pattern": "*", - "target_pattern": "*" + "rType_pattern": "*" } ] } ] -} \ No newline at end of file +} diff --git a/pkg/js/parse_tests/042-unmanaged.js b/pkg/js/parse_tests/042-unmanaged.js index 7c54b3853..eba2545e0 100644 --- a/pkg/js/parse_tests/042-unmanaged.js +++ b/pkg/js/parse_tests/042-unmanaged.js @@ -1,6 +1,9 @@ D("foo.com", "none" - , UNMANAGED("one") - , UNMANAGED("two", "A, CNAME") - , UNMANAGED("three", "TXT", "findme") - , UNMANAGED("notype", "", "targglob") + , UNMANAGED("", "", "targetGlob1") + , UNMANAGED("", "CNAME", "") + , UNMANAGED("", "A", "targetGlob3") + , UNMANAGED("lab4") + , UNMANAGED("notype", "", "targetGlob5") + , UNMANAGED("lab6", "A, CNAME") + , UNMANAGED("lab7", "TXT", "targetGlob7") ); diff --git a/pkg/js/parse_tests/042-unmanaged.json b/pkg/js/parse_tests/042-unmanaged.json index 07b24018a..724bd5db6 100644 --- a/pkg/js/parse_tests/042-unmanaged.json +++ b/pkg/js/parse_tests/042-unmanaged.json @@ -1,34 +1,40 @@ { - "registrars": [], "dns_providers": [], "domains": [ { - "name": "foo.com", - "registrar": "none", "dnsProviders": {}, + "name": "foo.com", "records": [], + "registrar": "none", "unmanaged": [ { - "label_pattern": "one", - "rType_pattern": "*", - "target_pattern": "*" + "target_pattern": "targetGlob1" }, { - "label_pattern": "two", - "rType_pattern": "A, CNAME", - "target_pattern": "*" + "rType_pattern": "CNAME" }, { - "label_pattern": "three", - "rType_pattern": "TXT", - "target_pattern": "findme" + "rType_pattern": "A", + "target_pattern": "targetGlob3" + }, + { + "label_pattern": "lab4" }, { "label_pattern": "notype", - "rType_pattern": "*", - "target_pattern": "targglob" + "target_pattern": "targetGlob5" + }, + { + "label_pattern": "lab6", + "rType_pattern": "A, CNAME" + }, + { + "label_pattern": "lab7", + "rType_pattern": "TXT", + "target_pattern": "targetGlob7" } ] } - ] -} \ No newline at end of file + ], + "registrars": [] +} diff --git a/pkg/js/parse_tests/044-ensureabsent.js b/pkg/js/parse_tests/044-ensureabsent.js new file mode 100644 index 000000000..85a0f961e --- /dev/null +++ b/pkg/js/parse_tests/044-ensureabsent.js @@ -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")), + {}); diff --git a/pkg/js/parse_tests/044-ensureabsent.json b/pkg/js/parse_tests/044-ensureabsent.json new file mode 100644 index 000000000..fbdfcf226 --- /dev/null +++ b/pkg/js/parse_tests/044-ensureabsent.json @@ -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": [] +} diff --git a/pkg/recorddb/recorddb.go b/pkg/recorddb/recorddb.go new file mode 100644 index 000000000..5000100e5 --- /dev/null +++ b/pkg/recorddb/recorddb.go @@ -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 +//} diff --git a/providers/bind/bindProvider.go b/providers/bind/bindProvider.go index 31c2db852..588eb7cdd 100644 --- a/providers/bind/bindProvider.go +++ b/providers/bind/bindProvider.go @@ -153,7 +153,6 @@ func (c *bindProvider) ListZones() ([]string, error) { // GetZoneRecords gets the records of a zone and returns them in RecordConfig format. func (c *bindProvider) GetZoneRecords(domain string) (models.Records, error) { - foundRecords := models.Records{} if _, err := os.Stat(c.directory); os.IsNotExist(err) { 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 - 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() { - rec, err := models.RRtoRC(rr, domain) + rec, err := models.RRtoRC(rr, zoneName) if err != nil { return nil, err } @@ -188,7 +194,7 @@ func (c *bindProvider) GetZoneRecords(domain string) (models.Records, error) { } 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 } diff --git a/providers/route53/route53Provider.go b/providers/route53/route53Provider.go index 693c6155a..475f1d957 100644 --- a/providers/route53/route53Provider.go +++ b/providers/route53/route53Provider.go @@ -489,7 +489,7 @@ func (r *route53Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*mode switch inst.Type { case diff2.REPORT: - corrections = append(corrections, &models.Correction{Msg: inst.MsgsJoined}) + chg = r53Types.Change{} case diff2.CREATE: fallthrough @@ -586,8 +586,9 @@ func (r *route53Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*mode func reorderInstructions(changes diff2.ChangeList) diff2.ChangeList { var main, tail diff2.ChangeList for _, change := range changes { - //if change.Key.Type == "R53_ALIAS" { - if strings.HasPrefix(change.Key.Type, "R53_ALIAS_") { + // Reports should be early in the list. + // R53_ALIAS_ records should go to the tail. + if change.Type != diff2.REPORT && strings.HasPrefix(change.Key.Type, "R53_ALIAS_") { tail = append(tail, change) } else { main = append(main, change) @@ -889,6 +890,11 @@ func (b *changeBatcher) Next() bool { c := &b.changes[end] // Check that we won't exceed 1000 ResourceRecords in the request. + if c.ResourceRecordSet == nil { + end++ + continue + } + rrsetSize := len(c.ResourceRecordSet.ResourceRecords) if c.Action == r53Types.ChangeActionUpsert { // "When the value of the Action element is UPSERT, each ResourceRecord element is counted twice."