Repository: StackExchange/dnscontrol
Pull request #2084 (2023-02-18 21:41:16)

Bugfixed: NO_PURGE now works on all diff2 providers
Fixes https://github.com/StackExchange/dnscontrol/issues/2077

and goes a long way to implementing https://github.com/StackExchange/dnscontrol/issues/2074 and https://github.com/StackExchange/dnscontrol/issues/2074 and https://github.com/StackExchange/dnscontrol/issues/2064


Patch:
diff --git a/models/domain.go b/models/domain.go
index 8c1b4cd7..fb5220bd 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 e8970070..d780fd0c 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 00000000..91630168
--- /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 00000000..36880faa
--- /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 258c36c9..fff0daa4 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 4ce45e55..3cd93a51 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/diff2.go b/pkg/diff2/diff2.go
index 02fcf669..38230049 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 30dacbec..ba9b1ed0 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 00000000..ebe1047d
--- /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/nopurge.go b/pkg/diff2/nopurge.go
deleted file mode 100644
index cf8bfd05..00000000
--- 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 5171752b..00000000
--- 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/js/helpers.js b/pkg/js/helpers.js
index 46a16eef..d937dfe5 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 676b111a..910dc443 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/recorddb/recorddb.go b/pkg/recorddb/recorddb.go
new file mode 100644
index 00000000..5000100e
--- /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 31c2db85..588eb7cd 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 693c6155..475f1d95 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."


Test patch:
diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go
index 59b34fdc..324d9aa9 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/pkg/diff2/compareconfig_test.go b/pkg/diff2/compareconfig_test.go
index 6c807f75..54e3da9c 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/handsoff_test.go b/pkg/diff2/handsoff_test.go
new file mode 100644
index 00000000..6d5b0fd1
--- /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/unmanaged_test.go b/pkg/diff2/unmanaged_test.go
deleted file mode 100644
index 6d0fd38b..00000000
--- 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/parse_tests/005-ignored-records.json b/pkg/js/parse_tests/005-ignored-records.json
index 2fd8a0c9..618f5b09 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 8ea43afd..97cce3ec 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 7c54b385..eba2545e 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 07b24018..724bd5db 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 00000000..85a0f961
--- /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 00000000..fbdfcf22
--- /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": []
+}