Repository: StackExchange/dnscontrol
Pull request #1870 (2022-12-30 21:27:12)

Add a backwards compatible interface to diff2


Patch:
diff --git a/pkg/diff/diff2compat.go b/pkg/diff/diff2compat.go
new file mode 100644
index 00000000..c43e2bde
--- /dev/null
+++ b/pkg/diff/diff2compat.go
@@ -0,0 +1,88 @@
+package diff
+
+import (
+	"github.com/StackExchange/dnscontrol/v3/models"
+	"github.com/StackExchange/dnscontrol/v3/pkg/diff2"
+)
+
+// NewCompat is a constructor that uses the new pkg/diff2 system
+// instead of pkg/diff.
+//
+// It is for backwards compatibility only. New providers should use
+// pkg/diff2.  Older providers should use this to reduce their
+// dependency on pkg/diff2 until they can move to the pkg/diff2/By*()
+// functions.
+//
+// To use this simply change New() to NewCompat(). If that doesn't
+// work please report a bug. The only exception is if you depend on
+// the extraValues feature, which will not be supported
+func NewCompat(dc *models.DomainConfig, extraValues ...func(*models.RecordConfig) map[string]string) Differ {
+	if len(extraValues) != 0 {
+		panic("extraValues not supported")
+	}
+
+	d := New(dc)
+	return &differCompat{
+		OldDiffer: d.(*differ),
+
+		dc: dc,
+	}
+}
+
+// differCompat meets the Differ interface but provides its service
+// using pkg/diff2 instead of pkg/diff.
+type differCompat struct {
+	OldDiffer *differ // Store the backwards-compatible "d" for pkg/diff
+
+	dc *models.DomainConfig
+}
+
+// IncrementalDiff generates the diff using the pkg/diff2 code.
+func (d *differCompat) IncrementalDiff(existing []*models.RecordConfig) (unchanged, create, toDelete, modify Changeset, err error) {
+	unchanged = Changeset{}
+	create = Changeset{}
+	toDelete = Changeset{}
+	modify = Changeset{}
+
+	instructions, err := diff2.ByRecord(existing, d.dc, nil)
+	if err != nil {
+		return nil, nil, nil, nil, err
+	}
+
+	for _, inst := range instructions {
+		cor := Correlation{d: d.OldDiffer}
+		switch inst.Type {
+		case diff2.CREATE:
+			cor.Desired = inst.New[0]
+			create = append(create, cor)
+		case diff2.CHANGE:
+			cor.Existing = inst.Old[0]
+			cor.Desired = inst.New[0]
+			modify = append(modify, cor)
+		case diff2.DELETE:
+			cor.Existing = inst.Old[0]
+			toDelete = append(toDelete, cor)
+		}
+	}
+
+	return
+}
+
+// ChangedGroups provides the same results as IncrementalDiff but grouped by key.
+func (d *differCompat) ChangedGroups(existing []*models.RecordConfig) (map[models.RecordKey][]string, error) {
+	changedKeys := map[models.RecordKey][]string{}
+	_, create, toDelete, modify, err := d.IncrementalDiff(existing)
+	if err != nil {
+		return nil, err
+	}
+	for _, c := range create {
+		changedKeys[c.Desired.Key()] = append(changedKeys[c.Desired.Key()], c.String())
+	}
+	for _, d := range toDelete {
+		changedKeys[d.Existing.Key()] = append(changedKeys[d.Existing.Key()], d.String())
+	}
+	for _, m := range modify {
+		changedKeys[m.Desired.Key()] = append(changedKeys[m.Desired.Key()], m.String())
+	}
+	return changedKeys, nil
+}
diff --git a/pkg/diff2/analyze.go b/pkg/diff2/analyze.go
index 6df621e7..9a6fd597 100644
--- a/pkg/diff2/analyze.go
+++ b/pkg/diff2/analyze.go
@@ -123,7 +123,7 @@ func mkAdd(l string, t string, msgs []string, recs models.Records) Change {
 func mkAddByLabel(l string, t string, msgs []string, newRecs models.Records) Change {
 	//fmt.Printf("DEBUG: mkAddByLabel: len(o)=%d len(m)=%d\n", len(newRecs), len(msgs))
 	//fmt.Printf("DEBUG: mkAddByLabel: msgs = %v\n", msgs)
-	c := Change{Type: CREATE, Msgs: msgs}
+	c := Change{Type: CREATE, Msgs: msgs, MsgsJoined: strings.Join(msgs, "\n")}
 	c.Key.NameFQDN = l
 	c.Key.Type = t
 	c.New = newRecs
@@ -131,7 +131,7 @@ func mkAddByLabel(l string, t string, msgs []string, newRecs models.Records) Cha
 }
 
 func mkChange(l string, t string, msgs []string, oldRecs, newRecs models.Records) Change {
-	c := Change{Type: CHANGE, Msgs: msgs}
+	c := Change{Type: CHANGE, Msgs: msgs, MsgsJoined: strings.Join(msgs, "\n")}
 	c.Key.NameFQDN = l
 	c.Key.Type = t
 	c.Old = oldRecs
@@ -141,7 +141,7 @@ func mkChange(l string, t string, msgs []string, oldRecs, newRecs models.Records
 
 func mkChangeLabel(l string, t string, msgs []string, oldRecs, newRecs models.Records, msgsByKey map[models.RecordKey][]string) Change {
 	//fmt.Printf("DEBUG: mkChangeLabel: len(o)=%d\n", len(oldRecs))
-	c := Change{Type: CHANGE, Msgs: msgs}
+	c := Change{Type: CHANGE, Msgs: msgs, MsgsJoined: strings.Join(msgs, "\n")}
 	c.Key.NameFQDN = l
 	c.Key.Type = t
 	c.Old = oldRecs
@@ -151,14 +151,14 @@ func mkChangeLabel(l string, t string, msgs []string, oldRecs, newRecs models.Re
 }
 
 func mkDelete(l string, t string, oldRecs models.Records, msgs []string) Change {
-	c := Change{Type: DELETE, Msgs: msgs}
+	c := Change{Type: DELETE, Msgs: msgs, MsgsJoined: strings.Join(msgs, "\n")}
 	c.Key.NameFQDN = l
 	c.Key.Type = t
 	c.Old = oldRecs
 	return c
 }
 func mkDeleteRec(l string, t string, msgs []string, rec *models.RecordConfig) Change {
-	c := Change{Type: DELETE, Msgs: msgs}
+	c := Change{Type: DELETE, Msgs: msgs, MsgsJoined: strings.Join(msgs, "\n")}
 	c.Key.NameFQDN = l
 	c.Key.Type = t
 	c.Old = models.Records{rec}
@@ -175,16 +175,70 @@ func removeCommon(existing, desired []targetConfig) ([]targetConfig, []targetCon
 
 	eKeys := map[string]*targetConfig{}
 	for _, v := range existing {
+		v := v
 		eKeys[v.compareable] = &v
 	}
 	dKeys := map[string]*targetConfig{}
 	for _, v := range desired {
+		v := v
 		dKeys[v.compareable] = &v
 	}
 
 	return filterBy(existing, dKeys), filterBy(desired, eKeys)
 }
 
+// Find the changes that are exclusively changes in TTL.
+func splitTTLOnly(existing, desired []targetConfig) (
+	existDiff []targetConfig, desireDiff []targetConfig,
+	existTTL models.Records, desireTTL models.Records,
+) {
+	ei := 0
+	di := 0
+
+	for (ei < len(existing)) && (di < len(desired)) {
+		er := existing[ei].rec
+		dr := desired[di].rec
+		ecomp := er.GetTargetCombined()
+		dcomp := dr.GetTargetCombined()
+
+		if ecomp == dcomp && er.TTL == dr.TTL {
+			panic("Should not happen. There should be some difference!")
+		}
+
+		//fmt.Printf("DEBUG ecomp=%q dcomp=%q ettl=%d dttl=%d\n", ecomp, dcomp, er.TTL, dr.TTL)
+		if ecomp == dcomp && er.TTL != dr.TTL {
+			//fmt.Printf("DEBUG: equal\n")
+			existTTL = append(existTTL, er)
+			desireTTL = append(desireTTL, dr)
+			ei++
+			di++
+		} else if ecomp < dcomp {
+			//fmt.Printf("DEBUG: less\n")
+			existDiff = append(existDiff, existing[ei])
+			ei++
+		} else if ecomp > dcomp {
+			//fmt.Printf("DEBUG: greater\n")
+			desireDiff = append(desireDiff, desired[di])
+			di++
+		} else {
+			panic("Should not happen. e and d can not be both equal, less and greater")
+		}
+
+	}
+
+	// Any remainder goes to the *Diff result:
+	if ei < len(existing) {
+		//fmt.Printf("DEBUG: append e len()=%d\n", ei)
+		existDiff = append(existDiff, existing[ei:]...)
+	}
+	if di < len(desired) {
+		//fmt.Printf("DEBUG: append d len()=%d\n", di)
+		desireDiff = append(desireDiff, desired[di:]...)
+	}
+
+	return
+}
+
 // Return s but remove any items that can be found in m.
 func filterBy(s []targetConfig, m map[string]*targetConfig) []targetConfig {
 	// fmt.Printf("DEBUG: filterBy called with %v\n", s)
@@ -212,6 +266,22 @@ func filterBy(s []targetConfig, m map[string]*targetConfig) []targetConfig {
 	return s
 }
 
+func humanDiff(a, b *models.RecordConfig) string {
+	acombined := a.GetTargetCombined()
+	bcombined := b.GetTargetCombined()
+	combinedDiff := acombined != bcombined
+	ttlDiff := a.TTL != b.TTL
+	// TODO(tlim): It would be nice if we included special cases for MX
+	// records and others.
+	if combinedDiff && ttlDiff {
+		return fmt.Sprintf("(%s ttl=%d) -> (%s ttl=%d)", acombined, a.TTL, bcombined, b.TTL)
+	}
+	if combinedDiff {
+		return fmt.Sprintf("(%s) -> (%s)", acombined, bcombined)
+	}
+	return fmt.Sprintf("%s (ttl %d->%d)", acombined, a.TTL, b.TTL)
+}
+
 func diffTargets(existing, desired []targetConfig) ChangeList {
 	//fmt.Printf("DEBUG: diffTargets called with len(e)=%d len(d)=%d\n", len(existing), len(desired))
 
@@ -230,17 +300,35 @@ func diffTargets(existing, desired []targetConfig) ChangeList {
 	// remove the exact matches.
 	existing, desired = removeCommon(existing, desired)
 
-	// the common chunk are changes
+	// At this point the exact matches are removed. However there may be
+	// records that have the same GetTargetCombined() but different
+	// TTLs.
+
+	// Find TTL changes:
+	existing, desired, existingTTL, desiredTTL := splitTTLOnly(existing, desired)
+	for i := range desiredTTL {
+		er := existingTTL[i]
+		dr := desiredTTL[i]
+
+		m := fmt.Sprintf("CHANGE %s %s ", dr.NameFQDN, dr.Type) + humanDiff(er, dr)
+
+		instructions = append(instructions, mkChange(dr.NameFQDN, dr.Type, []string{m},
+			models.Records{er},
+			models.Records{dr},
+		))
+	}
+
+	// the common chunk are changes (regardless of TTL)
 	mi := min(len(existing), len(desired))
 	//fmt.Printf("DEBUG: min=%d\n", mi)
 	for i := 0; i < mi; i++ {
 		//fmt.Println(i, "CHANGE")
 		er := existing[i].rec
 		dr := desired[i].rec
-		m := fmt.Sprintf("CHANGE %s %s (%s) -> (%s)", dr.NameFQDN, dr.Type, er.GetTargetCombined(), dr.GetTargetCombined())
+
+		m := fmt.Sprintf("CHANGE %s %s ", dr.NameFQDN, dr.Type) + humanDiff(existing[i].rec, desired[i].rec)
+
 		instructions = append(instructions, mkChange(dr.NameFQDN, dr.Type, []string{m},
-			//models.Records{existing[i].rec},
-			//models.Records{desired[i].rec},
 			models.Records{er},
 			models.Records{dr},
 		))
diff --git a/pkg/diff2/compareconfig.go b/pkg/diff2/compareconfig.go
index 368f352f..b9a24bfa 100644
--- a/pkg/diff2/compareconfig.go
+++ b/pkg/diff2/compareconfig.go
@@ -202,10 +202,12 @@ func (cc *CompareConfig) addRecords(recs models.Records, storeInExisting bool) {
 		//fmt.Printf("BEFORE E/D: %v/%v\n", len(td.existingRecs), len(td.desiredRecs))
 		if storeInExisting {
 			cc.ldata[labelIdx].tdata[rtIdx].existingRecs = append(cc.ldata[labelIdx].tdata[rtIdx].existingRecs, rec)
-			cc.ldata[labelIdx].tdata[rtIdx].existingTargets = append(cc.ldata[labelIdx].tdata[rtIdx].existingTargets, targetConfig{compareable: comp, rec: rec})
+			cc.ldata[labelIdx].tdata[rtIdx].existingTargets = append(cc.ldata[labelIdx].tdata[rtIdx].existingTargets,
+				targetConfig{compareable: comp, rec: rec})
 		} else {
 			cc.ldata[labelIdx].tdata[rtIdx].desiredRecs = append(cc.ldata[labelIdx].tdata[rtIdx].desiredRecs, rec)
-			cc.ldata[labelIdx].tdata[rtIdx].desiredTargets = append(cc.ldata[labelIdx].tdata[rtIdx].desiredTargets, targetConfig{compareable: comp, rec: rec})
+			cc.ldata[labelIdx].tdata[rtIdx].desiredTargets = append(cc.ldata[labelIdx].tdata[rtIdx].desiredTargets,
+				targetConfig{compareable: comp, rec: rec})
 		}
 		//fmt.Printf("AFTER  L: %v\n", len(cc.ldata))
 		//fmt.Printf("AFTER  E/D: %v/%v\n", len(td.existingRecs), len(td.desiredRecs))
diff --git a/pkg/diff2/compat.go b/pkg/diff2/compat.go
new file mode 100644
index 00000000..9e67a76d
--- /dev/null
+++ b/pkg/diff2/compat.go
@@ -0,0 +1,85 @@
+package diff2
+
+// import (
+// 	"github.com/StackExchange/dnscontrol/v3/models"
+// 	"github.com/StackExchange/dnscontrol/v3/pkg/diff"
+// )
+
+// // Provide an interface that is backwards compatible with pkg/diff.
+
+// // // /diff/IncrementalDiffCompat(). It not as efficient as converting
+// // to the diff2.By*() functions. However, using this is better than
+// // staying with pkg/diff.
+// func CompatIncrementalDiff(dc *models.DomainConfig, existing []*models.RecordConfig) (unchanged, create, toDelete, modify diff.Changeset, err error) {
+// 	unchanged = diff.Changeset{}
+// 	create = diff.Changeset{}
+// 	toDelete = diff.Changeset{}
+// 	modify = diff.Changeset{}
+
+// 	instructions, err := ByRecord(existing, dc, nil)
+// 	if err != nil {
+// 		return nil, nil, nil, nil, err
+// 	}
+
+// 	d := diff.New(dc, nil)
+
+// 	for _, inst := range instructions {
+// 		//cor := Correlation{d: d}
+// 		cor := diff.Correlation{d: d}
+// 		switch inst.Type {
+// 		case CREATE:
+// 			cor.Desired = inst.New[0]
+// 			create = append(create, cor)
+// 		case CHANGE:
+// 			cor.Existing = inst.Old[0]
+// 			cor.Desired = inst.New[0]
+// 			modify = append(modify, cor)
+// 		case DELETE:
+// 			cor.Existing = inst.Old[0]
+// 			toDelete = append(toDelete, cor)
+// 		}
+// 	}
+
+// 	return
+// }
+
+// // func (d *Differ) ChangedGroups(existing []*models.RecordConfig) (map[models.RecordKey][]string, error) {
+// // 	changedKeys := map[models.RecordKey][]string{}
+// // 	_, create, toDelete, modify, err := d.IncrementalDiff(existing)
+// // 	if err != nil {
+// // 		return nil, err
+// // 	}
+// // 	for _, c := range create {
+// // 		changedKeys[c.Desired.Key()] = append(changedKeys[c.Desired.Key()], c.String())
+// // 	}
+// // 	for _, d := range toDelete {
+// // 		changedKeys[d.Existing.Key()] = append(changedKeys[d.Existing.Key()], d.String())
+// // 	}
+// // 	for _, m := range modify {
+// // 		changedKeys[m.Desired.Key()] = append(changedKeys[m.Desired.Key()], m.String())
+// // 	}
+// // 	return changedKeys, nil
+// // }
+
+// // func (c Correlation) String() string {
+// // 	if c.Existing == nil {
+// // 		return fmt.Sprintf("CREATE %s %s %s", c.Desired.Type, c.Desired.GetLabelFQDN(), c.d.content(c.Desired))
+// // 	}
+// // 	if c.Desired == nil {
+// // 		return fmt.Sprintf("DELETE %s %s %s", c.Existing.Type, c.Existing.GetLabelFQDN(), c.d.content(c.Existing))
+// // 	}
+// // 	return fmt.Sprintf("MODIFY %s %s: (%s) -> (%s)", c.Existing.Type, c.Existing.GetLabelFQDN(), c.d.content(c.Existing), c.d.content(c.Desired))
+// // }
+
+// // // get normalized content for record. target, ttl, mxprio, and specified metadata
+// // func (d *Differ) content(r *models.RecordConfig) string {
+
+// // 	// get the extra values maps to add to the comparison.
+// // 	var allMaps []map[string]string
+// // 	for _, f := range d.extraValues {
+// // 		valueMap := f(r)
+// // 		allMaps = append(allMaps, valueMap)
+// // 	}
+
+// // 	return r.ToDiffable(allMaps...)
+// // }
diff --git a/pkg/diff2/diff2.go b/pkg/diff2/diff2.go
index a3c9baa3..f5d5bd84 100644
--- a/pkg/diff2/diff2.go
+++ b/pkg/diff2/diff2.go
@@ -26,13 +26,59 @@ type ChangeList []Change
 type Change struct {
 	Type Verb // Add, Change, Delete
 
-	Key       models.RecordKey // .Key.Type is "" unless using ByRecordSet
-	Old       models.Records
-	New       models.Records                // any changed or added records at Key.
-	Msgs      []string                      // Human-friendly explanation of what changed
-	MsgsByKey map[models.RecordKey][]string // Messages for a given key
+	Key        models.RecordKey // .Key.Type is "" unless using ByRecordSet
+	Old        models.Records
+	New        models.Records                // any changed or added records at Key.
+	Msgs       []string                      // Human-friendly explanation of what changed
+	MsgsJoined string                        // strings.Join(Msgs, "\n")
+	MsgsByKey  map[models.RecordKey][]string // Messages for a given key
 }
 
+/*
+General instructions:
+
+  changes, err := diff2.ByRecord(existing, dc, nil)
+  //changes, err := diff2.ByRecordSet(existing, dc, nil)
+  //changes, err := diff2.ByLabel(existing, dc, nil)
+  if err != nil {
+    return nil, err
+  }
+
+  var corrections []*models.Correction
+
+  for _, change := range changes {
+    switch change.Type {
+    case diff2.CREATE:
+      corr = &models.Correction{
+        Msg: change.MsgsJoined,
+        F: func() error {
+          return c.createRecord(FILL IN)
+        },
+      }
+    case diff2.CHANGE:
+      corr = &models.Correction{
+        Msg: change.MsgsJoined,
+        F: func() error {
+          return c.modifyRecord(FILL IN)
+        },
+      }
+    case diff2.DELETE:
+      corr = &models.Correction{
+        Msg: change.MsgsJoined,
+        F: func() error {
+          return c.deleteRecord(FILL IN)
+        },
+      }
+    }
+    corrections = append(corrections, corr)
+  }
+
+  return corrections, nil
+}
+
+
+*/
+
 // ByRecordSet takes two lists of records (existing and desired) and
 // returns instructions for turning existing into desired.
 //
@@ -143,18 +189,6 @@ func ByZone(existing models.Records, dc *models.DomainConfig, compFunc Comparabl
 	return justMsgs(instructions), len(instructions) != 0, nil
 }
 
-/*
-nil, nil :
-nil, nonzero :   nil, true, nil
-nonzero, nil :   msgs, true, nil
-nonzero, nonzero :
-
-existing: changes       : return msgs, true, nil
-existing: no changes    : return nil, false, nil
-not existing: no changes: return nil, false, nil
-not existing: changes   : return nil, true, nil
-*/
-
 func (c Change) String() string {
 	var buf bytes.Buffer
 	b := &buf
diff --git a/providers/namedotcom/records.go b/providers/namedotcom/records.go
index f3d6e24f..e1e7a502 100644
--- a/providers/namedotcom/records.go
+++ b/providers/namedotcom/records.go
@@ -55,42 +55,42 @@ func (n *namedotcomProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*m
 	models.PostProcessRecords(actual)
 
 	var corrections []*models.Correction
-	if !diff2.EnableDiff2 || true { // Remove "|| true" when diff2 version arrives
 
-		differ := diff.New(dc)
-		_, create, del, mod, err := differ.IncrementalDiff(actual)
-		if err != nil {
-			return nil, err
-		}
+	var create, del, mod diff.Changeset
 
-		corrections := []*models.Correction{}
-
-		for _, d := range del {
-			rec := d.Existing.Original.(*namecom.Record)
-			c := &models.Correction{Msg: d.String(), F: func() error { return n.deleteRecord(rec.ID, dc.Name) }}
-			corrections = append(corrections, c)
-		}
-		for _, cre := range create {
-			rec := cre.Desired
-			c := &models.Correction{Msg: cre.String(), F: func() error { return n.createRecord(rec, dc.Name) }}
-			corrections = append(corrections, c)
-		}
-		for _, chng := range mod {
-			old := chng.Existing.Original.(*namecom.Record)
-			new := chng.Desired
-			c := &models.Correction{Msg: chng.String(), F: func() error {
-				err := n.deleteRecord(old.ID, dc.Name)
-				if err != nil {
-					return err
-				}
-				return n.createRecord(new, dc.Name)
-			}}
-			corrections = append(corrections, c)
-		}
-		return corrections, nil
+	if !diff2.EnableDiff2 {
+		differ := diff.New(dc)
+		_, create, del, mod, err = differ.IncrementalDiff(actual)
+	} else {
+		differ := diff.NewCompat(dc)
+		_, create, del, mod, err = differ.IncrementalDiff(actual)
+	}
+	if err != nil {
+		return nil, err
 	}
 
-	// Insert Future diff2 version here.
+	for _, d := range del {
+		rec := d.Existing.Original.(*namecom.Record)
+		c := &models.Correction{Msg: d.String(), F: func() error { return n.deleteRecord(rec.ID, dc.Name) }}
+		corrections = append(corrections, c)
+	}
+	for _, cre := range create {
+		rec := cre.Desired
+		c := &models.Correction{Msg: cre.String(), F: func() error { return n.createRecord(rec, dc.Name) }}
+		corrections = append(corrections, c)
+	}
+	for _, chng := range mod {
+		old := chng.Existing.Original.(*namecom.Record)
+		new := chng.Desired
+		c := &models.Correction{Msg: chng.String(), F: func() error {
+			err := n.deleteRecord(old.ID, dc.Name)
+			if err != nil {
+				return err
+			}
+			return n.createRecord(new, dc.Name)
+		}}
+		corrections = append(corrections, c)
+	}
 
 	return corrections, nil
 }


Test patch:
diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go
index 2f56ac0f..9682bca9 100644
--- a/integrationTest/integration_test.go
+++ b/integrationTest/integration_test.go
@@ -703,8 +703,18 @@ func makeTests(t *testing.T) []*TestGroup {
 			tc("Change single target from set", ttl(a("@", "1.2.3.4"), 1000), a("www", "2.2.2.2"), a("www", "5.6.7.8")),
 			tc("Change all ttls", ttl(a("@", "1.2.3.4"), 500), ttl(a("www", "2.2.2.2"), 400), ttl(a("www", "5.6.7.8"), 400)),
 			tc("Delete one", ttl(a("@", "1.2.3.4"), 500), ttl(a("www", "5.6.7.8"), 400)),
-			tc("Add back and change ttl", ttl(a("www", "5.6.7.8"), 700), ttl(a("www", "1.2.3.4"), 700)),
-			tc("Change targets and ttls", a("www", "1.1.1.1"), a("www", "2.2.2.2")),
+		),
+
+		testgroup("add to existing label",
+			tc("Setup", ttl(a("www", "5.6.7.8"), 400)),
+			tc("Add at same label", ttl(a("www", "5.6.7.8"), 400), ttl(a("www", "1.2.3.4"), 400)),
+		),
+
+		// 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",
+			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)),
 		),
 
 		testgroup("Protocol-Wildcard",
diff --git a/integrationTest/readme.md b/integrationTest/readme.md
index 2189ef1d..49046274 100644
--- a/integrationTest/readme.md
+++ b/integrationTest/readme.md
@@ -39,3 +39,20 @@ ProTip: If you run these tests frequently (and we hope you do), you
 should create a script that you can `source` to set these
 variables. Be careful not to check this script into Git since it
 contains credentials.
+
+## Debugger
+
+Test a particular function:
+
+```
+dlv test github.com/StackExchange/dnscontrol/v3/pkg/diff2 -- -test.run Test_analyzeByRecordSet
+                                                ^^^^^^^^^
+                                                Assumes you are in the pkg/diff2 directory.
+```
+
+Debug the integration tests:
+
+
+```
+dlv test github.com/StackExchange/dnscontrol/v3/integrationTest -- -test.v -test.run ^TestDNSProviders -verbose -provider NAMEDOTCOM -start 1 -end 1 -diff2
+```
diff --git a/pkg/diff2/analyze_test.go b/pkg/diff2/analyze_test.go
index cba20614..b0992377 100644
--- a/pkg/diff2/analyze_test.go
+++ b/pkg/diff2/analyze_test.go
@@ -9,7 +9,7 @@ import (
 	"github.com/kylelemons/godebug/diff"
 )
 
-var testDataAA1234 = makeRec("laba", "A", "1.2.3.4")               //  [0]
+var testDataAA1234 = makeRec("laba", "A", "1.2.3.4")               //      [0]
 var testDataAA5678 = makeRec("laba", "A", "5.6.7.8")               //
 var testDataAA1234ttl700 = makeRecTTL("laba", "A", "1.2.3.4", 700) //
 var testDataAA5678ttl700 = makeRecTTL("laba", "A", "5.6.7.8", 700) //
@@ -42,8 +42,6 @@ var d11 = makeRec("labg", "NS", "10.10.10.97")            // [11']
 var d12 = makeRec("labh", "A", "1.2.3.4")                 //      [12']
 var testDataApexMX22bbb = makeRec("", "MX", "22 bbb")
 
-var d0tc = mkTargetConfig(testDataAA1234clone)
-
 func makeChange(v Verb, l, t string, old, new models.Records, msgs []string) Change {
 	c := Change{
 		Type: v,
@@ -425,6 +423,7 @@ func mkTargetConfigMap(x ...*models.RecordConfig) map[string]*targetConfig {
 }
 
 func Test_diffTargets(t *testing.T) {
+
 	type args struct {
 		existing []targetConfig
 		desired  []targetConfig
@@ -435,6 +434,24 @@ func Test_diffTargets(t *testing.T) {
 		want ChangeList
 	}{
 
+		{
+			name: "add1changettl",
+			args: args{
+				existing: mkTargetConfig(testDataAA5678),
+				desired:  mkTargetConfig(testDataAA5678ttl700, testDataAA1234ttl700),
+			},
+			want: ChangeList{
+				Change{Type: CHANGE,
+					Key: models.RecordKey{NameFQDN: "laba.f.com", Type: "A"},
+					New: models.Records{testDataAA5678ttl700, testDataAA1234ttl700},
+					Msgs: []string{
+						"CHANGE laba.f.com A 5.6.7.8 (ttl 300->700)",
+						"CREATE laba.f.com A 1.2.3.4",
+					},
+				},
+			},
+		},
+
 		{
 			name: "single",
 			args: args{
@@ -530,15 +547,26 @@ func Test_removeCommon(t *testing.T) {
 		want  []targetConfig
 		want1 []targetConfig
 	}{
+
 		{
 			name: "same",
 			args: args{
-				existing: d0tc,
-				desired:  d0tc,
+				existing: mkTargetConfig(testDataAA1234clone),
+				desired:  mkTargetConfig(testDataAA1234clone),
 			},
 			want:  []targetConfig{},
 			want1: []targetConfig{},
 		},
+
+		{
+			name: "disjoint",
+			args: args{
+				existing: mkTargetConfig(testDataAA1234),
+				desired:  mkTargetConfig(testDataAA5678),
+			},
+			want:  mkTargetConfig(testDataAA1234),
+			want1: mkTargetConfig(testDataAA5678),
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -607,3 +635,60 @@ func Test_filterBy(t *testing.T) {
 		})
 	}
 }
+
+func Test_splitTTLOnly(t *testing.T) {
+	type args struct {
+		existing []targetConfig
+		desired  []targetConfig
+	}
+	tests := []struct {
+		name           string
+		args           args
+		wantExistDiff  []targetConfig
+		wantDesireDiff []targetConfig
+		wantExistTTL   models.Records
+		wantDesireTTL  models.Records
+	}{
+
+		{
+			name: "simple",
+			args: args{
+				existing: mkTargetConfig(testDataAA1234),
+				desired:  mkTargetConfig(testDataAA1234ttl700),
+			},
+			wantExistDiff:  mkTargetConfig(),
+			wantDesireDiff: mkTargetConfig(),
+			wantExistTTL:   models.Records{testDataAA1234},
+			wantDesireTTL:  models.Records{testDataAA1234ttl700},
+		},
+
+		{
+			name: "both",
+			args: args{
+				existing: mkTargetConfig(testDataAA1234),
+				desired:  mkTargetConfig(testDataAA5678),
+			},
+			wantExistDiff:  mkTargetConfig(testDataAA1234),
+			wantDesireDiff: mkTargetConfig(testDataAA5678),
+			wantExistTTL:   models.Records{},
+			wantDesireTTL:  models.Records{},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotExistDiff, gotDesireDiff, gotExistTTL, gotDesireTTL := splitTTLOnly(tt.args.existing, tt.args.desired)
+			if !reflect.DeepEqual(gotExistDiff, tt.wantExistDiff) {
+				t.Errorf("splitTTLOnly() gotExistDiff = %v, want %v", gotExistDiff, tt.wantExistDiff)
+			}
+			if !reflect.DeepEqual(gotDesireDiff, tt.wantDesireDiff) {
+				t.Errorf("splitTTLOnly() gotDesireDiff = %v, want %v", gotDesireDiff, tt.wantDesireDiff)
+			}
+			if ((len(tt.wantExistTTL) != 0) && len(gotExistTTL) != 0) && !reflect.DeepEqual(gotExistTTL, tt.wantExistTTL) {
+				t.Errorf("splitTTLOnly() gotExistTTL = %v, want %v (len=%d %d)", gotExistTTL, tt.wantExistTTL, len(gotExistTTL), len(tt.wantExistTTL))
+			}
+			if ((len(tt.wantDesireTTL) != 0) && len(gotDesireTTL) != 0) && !reflect.DeepEqual(gotDesireTTL, tt.wantDesireTTL) {
+				t.Errorf("splitTTLOnly() gotDesireTTL = %v, want %v", gotDesireTTL, tt.wantDesireTTL)
+			}
+		})
+	}
+}