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

NEW FEATURE: diff2: A better "diff" mechanism (#1852)

This commit is contained in:
Tom Limoncelli
2022-12-11 17:28:58 -05:00
committed by GitHub
parent b0f2945510
commit 54fc2e9ce3
40 changed files with 2581 additions and 81 deletions

275
pkg/diff2/analyze.go Normal file
View File

@@ -0,0 +1,275 @@
package diff2
import (
"fmt"
"sort"
"strings"
"github.com/StackExchange/dnscontrol/v3/models"
)
func analyzeByRecordSet(cc *CompareConfig) ChangeList {
var instructions ChangeList
// For each label...
for _, lc := range cc.ldata {
// for each type at that label...
for _, rt := range lc.tdata {
// ...if there are changes generate an instruction.
ets := rt.existingTargets
dts := rt.desiredTargets
msgs := genmsgs(ets, dts)
if len(msgs) == 0 { // No differences?
//fmt.Printf("DEBUG: done. Records are the same\n")
// The records at this rset are the same. No work to be done.
continue
}
if len(ets) == 0 { // Create a new label.
//fmt.Printf("DEBUG: add\n")
instructions = append(instructions, mkAdd(lc.label, rt.rType, msgs, rt.desiredRecs))
} else if len(dts) == 0 { // Delete that label and all its records.
//fmt.Printf("DEBUG: delete\n")
instructions = append(instructions, mkDelete(lc.label, rt.rType, rt.existingRecs, msgs))
} else { // Change the records at that label
//fmt.Printf("DEBUG: change\n")
instructions = append(instructions, mkChange(lc.label, rt.rType, msgs, rt.existingRecs, rt.desiredRecs))
}
}
}
return instructions
}
func analyzeByLabel(cc *CompareConfig) ChangeList {
var instructions ChangeList
//fmt.Printf("DEBUG: START: analyzeByLabel\n")
// Accumulate if there are any changes and collect the info needed to generate instructions.
for i, lc := range cc.ldata {
//fmt.Printf("DEBUG: START LABEL = %q\n", lc.label)
label := lc.label
var accMsgs []string
var accExisting models.Records
var accDesired models.Records
msgsByKey := map[models.RecordKey][]string{}
for _, rt := range lc.tdata {
//fmt.Printf("DEBUG: START RTYPE = %q\n", rt.rType)
ets := rt.existingTargets
dts := rt.desiredTargets
msgs := genmsgs(ets, dts)
msgsByKey[models.RecordKey{NameFQDN: label, Type: rt.rType}] = msgs
//fmt.Printf("DEBUG: appending msgs=%v\n", msgs)
accMsgs = append(accMsgs, msgs...) // Accumulate the messages
accExisting = append(accExisting, rt.existingRecs...) // Accumulate records existing at this label.
accDesired = append(accDesired, rt.desiredRecs...) // Accumulate records desired at this label.
}
// We now know what changed (accMsgs),
// what records USED TO EXIST at that label (accExisting),
// and what records SHOULD EXIST at that label (accDesired).
// Based on that info, we can generate the instructions.
if len(accMsgs) == 0 { // Nothing changed.
//fmt.Printf("DEBUG: analyzeByLabel: %02d: no change\n", i)
} else if len(accDesired) == 0 { // No new records at the label? This must be a delete.
//fmt.Printf("DEBUG: analyzeByLabel: %02d: delete\n", i)
instructions = append(instructions, mkDelete(label, "", accExisting, accMsgs))
} else if len(accExisting) == 0 { // No old records at the label? This must be a change.
//fmt.Printf("DEBUG: analyzeByLabel: %02d: create\n", i)
fmt.Printf("DEBUG: analyzeByLabel mkAdd msgs=%d\n", len(accMsgs))
instructions = append(instructions, mkAddByLabel(label, "", accMsgs, accDesired))
} else { // If we get here, it must be a change.
_ = i
// fmt.Printf("DEBUG: analyzeByLabel: %02d: change %d{%v} %d{%v} msgs=%v\n", i,
// len(accExisting), accExisting,
// len(accDesired), accDesired,
// accMsgs,
// )
fmt.Printf("DEBUG: analyzeByLabel mkchange msgs=%d\n", len(accMsgs))
instructions = append(instructions, mkChangeLabel(label, "", accMsgs, accExisting, accDesired, msgsByKey))
}
}
return instructions
}
func analyzeByRecord(cc *CompareConfig) ChangeList {
//fmt.Printf("DEBUG: analyzeByRecord: cc=%v\n", cc)
var instructions ChangeList
// For each label, for each type at that label, see if there are any changes.
for _, lc := range cc.ldata {
//fmt.Printf("DEBUG: analyzeByRecord: next lc=%v\n", lc)
for _, rt := range lc.tdata {
ets := rt.existingTargets
dts := rt.desiredTargets
cs := diffTargets(ets, dts)
//fmt.Printf("DEBUG: analyzeByRecord: cs=%v\n", cs)
instructions = append(instructions, cs...)
}
}
return instructions
}
// NB(tlim): there is no analyzeByZone. ByZone calls anayzeByRecords().
func mkAdd(l string, t string, msgs []string, recs models.Records) Change {
c := Change{Type: CREATE, Msgs: msgs}
c.Key.NameFQDN = l
c.Key.Type = t
c.New = recs
return c
}
// TODO(tlim): Clean these up. Some of them are exact duplicates!
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.Key.NameFQDN = l
c.Key.Type = t
c.New = newRecs
return c
}
func mkChange(l string, t string, msgs []string, oldRecs, newRecs models.Records) Change {
c := Change{Type: CHANGE, Msgs: msgs}
c.Key.NameFQDN = l
c.Key.Type = t
c.Old = oldRecs
c.New = newRecs
return c
}
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.Key.NameFQDN = l
c.Key.Type = t
c.Old = oldRecs
c.New = newRecs
c.MsgsByKey = msgsByKey
return c
}
func mkDelete(l string, t string, oldRecs models.Records, msgs []string) Change {
c := Change{Type: DELETE, Msgs: msgs}
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.Key.NameFQDN = l
c.Key.Type = t
c.Old = models.Records{rec}
return c
}
func removeCommon(existing, desired []targetConfig) ([]targetConfig, []targetConfig) {
// NB(tlim): We could probably make this faster. Some ideas:
// * pre-allocate newexisting/newdesired and assign to indexed elements instead of appending.
// * iterate backwards (len(d) to 0) and delete items that are the same.
// On the other hand, this function typically receives lists of 1-3 elements
// and any optimization is probably fruitless.
eKeys := map[string]*targetConfig{}
for _, v := range existing {
eKeys[v.compareable] = &v
}
dKeys := map[string]*targetConfig{}
for _, v := range desired {
dKeys[v.compareable] = &v
}
return filterBy(existing, dKeys), filterBy(desired, eKeys)
}
func filterBy(s []targetConfig, m map[string]*targetConfig) []targetConfig {
i := 0 // output index
for _, x := range s {
if _, ok := m[x.compareable]; !ok {
// copy and increment index
s[i] = x
i++
}
}
// // Prevent memory leak by erasing truncated values
// // (not needed if values don't contain pointers, directly or indirectly)
// for j := i; j < len(s); j++ {
// s[j] = nil
// }
s = s[:i]
return s
}
func diffTargets(existing, desired []targetConfig) ChangeList {
//fmt.Printf("DEBUG: diffTargets called with len(e)=%d len(d)=%d\n", len(existing), len(desired))
// Nothing to do?
if len(existing) == 0 && len(desired) == 0 {
//fmt.Printf("DEBUG: diffTargets: nothing to do\n")
return nil
}
// Sort to make comparisons easier
sort.Slice(existing, func(i, j int) bool { return existing[i].compareable < existing[j].compareable })
sort.Slice(desired, func(i, j int) bool { return desired[i].compareable < desired[j].compareable })
var instructions ChangeList
// remove the exact matches.
existing, desired = removeCommon(existing, desired)
// the common chunk are changes
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())
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},
))
}
// any left-over existing are deletes
for i := mi; i < len(existing); i++ {
//fmt.Println(i, "DEL")
er := existing[i].rec
m := fmt.Sprintf("DELETE %s %s %s", er.NameFQDN, er.Type, er.GetTargetCombined())
instructions = append(instructions, mkDeleteRec(er.NameFQDN, er.Type, []string{m}, er))
}
// any left-over desired are creates
for i := mi; i < len(desired); i++ {
//fmt.Println(i, "CREATE")
dr := desired[i].rec
m := fmt.Sprintf("CREATE %s %s %s", dr.NameFQDN, dr.Type, dr.GetTargetCombined())
instructions = append(instructions, mkAdd(dr.NameFQDN, dr.Type, []string{m}, models.Records{dr}))
}
return instructions
}
func genmsgs(existing, desired []targetConfig) []string {
cl := diffTargets(existing, desired)
return justMsgs(cl)
}
func justMsgs(cl ChangeList) []string {
var msgs []string
for _, c := range cl {
msgs = append(msgs, c.Msgs...)
}
return msgs
}
func justMsgString(cl ChangeList) string {
msgs := justMsgs(cl)
return strings.Join(msgs, "\n")
}

635
pkg/diff2/analyze_test.go Normal file
View File

@@ -0,0 +1,635 @@
package diff2
import (
"reflect"
"strings"
"testing"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/kylelemons/godebug/diff"
)
var testDataAA1234 = makeRec("laba", "A", "1.2.3.4") // [0]
var testDataAA5678 = makeRec("laba", "A", "5.6.7.8") // [0]
var testDataAMX10a = makeRec("laba", "MX", "10 laba") // [1]
var testDataCCa = makeRec("labc", "CNAME", "laba") // [2]
var testDataEA15 = makeRec("labe", "A", "10.10.10.15") // [3]
var e4 = makeRec("labe", "A", "10.10.10.16") // [4]
var e5 = makeRec("labe", "A", "10.10.10.17") // [5]
var e6 = makeRec("labe", "A", "10.10.10.18") // [6]
var e7 = makeRec("labg", "NS", "10.10.10.15") // [7]
var e8 = makeRec("labg", "NS", "10.10.10.16") // [8]
var e9 = makeRec("labg", "NS", "10.10.10.17") // [9]
var e10 = makeRec("labg", "NS", "10.10.10.18") // [10]
var e11mx = makeRec("labh", "MX", "22 ttt") // [11]
var e11 = makeRec("labh", "CNAME", "labd") // [11]
var testDataApexMX1aaa = makeRec("", "MX", "1 aaa")
var testDataAA1234clone = makeRec("laba", "A", "1.2.3.4") // [0']
var testDataAA12345 = makeRec("laba", "A", "1.2.3.5") // [1']
var testDataAMX20b = makeRec("laba", "MX", "20 labb") // [2']
var d3 = makeRec("labe", "A", "10.10.10.95") // [3']
var d4 = makeRec("labe", "A", "10.10.10.96") // [4']
var d5 = makeRec("labe", "A", "10.10.10.97") // [5']
var d6 = makeRec("labe", "A", "10.10.10.98") // [6']
var d7 = makeRec("labf", "TXT", "foo") // [7']
var d8 = makeRec("labg", "NS", "10.10.10.10") // [8']
var d9 = makeRec("labg", "NS", "10.10.10.15") // [9']
var d10 = makeRec("labg", "NS", "10.10.10.16") // [10']
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 = targetConfig{compareable: "1.2.3.4 ttl=0", rec: testDataAA1234clone}
func makeChange(v Verb, l, t string, old, new models.Records, msgs []string) Change {
c := Change{
Type: v,
Old: old,
New: new,
Msgs: msgs,
}
c.Key.NameFQDN = l
c.Key.Type = t
return c
}
func compareMsgs(t *testing.T, fnname, testname, testpart string, gotcc ChangeList, wantstring string) {
t.Helper()
gs := strings.TrimSpace(justMsgString(gotcc))
ws := strings.TrimSpace(wantstring)
d := diff.Diff(gs, ws)
if d != "" {
t.Errorf("%s()/%s (wantMsgs:%s):\n===got===\n%s\n===want===\n%s\n===diff===\n%s\n===", fnname, testname, testpart, gs, ws, d)
}
}
func compareCL(t *testing.T, fnname, testname, testpart string, gotcl ChangeList, wantstring string) {
t.Helper()
gs := strings.TrimSpace(gotcl.String())
ws := strings.TrimSpace(wantstring)
d := diff.Diff(gs, ws)
if d != "" {
t.Errorf("%s()/%s (wantChange%s):\n===got===\n%s\n===want===\n%s\n===diff===\n%s\n===", fnname, testname, testpart, gs, ws, d)
}
}
func Test_analyzeByRecordSet(t *testing.T) {
type args struct {
origin string
existing, desired models.Records
compFn ComparableFunc
}
origin := "f.com"
existing := models.Records{testDataAA1234, testDataAMX10a, testDataCCa, testDataEA15, e4, e5, e6, e7, e8, e9, e10, e11}
desired := models.Records{testDataAA1234clone, testDataAA12345, testDataAMX20b, d3, d4, d5, d6, d7, d8, d9, d10, d11, d12}
tests := []struct {
name string
args args
wantMsgs string
wantChangeRSet string
wantChangeLabel string
wantChangeRec string
wantChangeZone string
}{
{
name: "oneequal",
args: args{
origin: origin,
existing: models.Records{testDataAA1234},
desired: models.Records{testDataAA1234clone},
},
wantMsgs: "", // Empty
wantChangeRSet: "ChangeList: len=0",
wantChangeLabel: "ChangeList: len=0",
wantChangeRec: "ChangeList: len=0",
},
{
name: "onediff",
args: args{
origin: origin,
existing: models.Records{testDataAA1234, testDataAMX10a},
desired: models.Records{testDataAA1234clone, testDataAMX20b},
},
wantMsgs: "CHANGE laba.f.com MX (10 laba) -> (20 labb)",
wantChangeRSet: `
ChangeList: len=1
00: Change: verb=CHANGE
key={laba.f.com MX}
old=[10 laba]
new=[20 labb]
msg=["CHANGE laba.f.com MX (10 laba) -> (20 labb)"]
`,
wantChangeLabel: `
ChangeList: len=1
00: Change: verb=CHANGE
key={laba.f.com }
old=[1.2.3.4 10 laba]
new=[1.2.3.4 20 labb]
msg=["CHANGE laba.f.com MX (10 laba) -> (20 labb)"]
`,
wantChangeRec: `
ChangeList: len=1
00: Change: verb=CHANGE
key={laba.f.com MX}
old=[10 laba]
new=[20 labb]
msg=["CHANGE laba.f.com MX (10 laba) -> (20 labb)"]
`,
},
{
name: "apex",
args: args{
origin: origin,
existing: models.Records{testDataAA1234, testDataApexMX1aaa},
desired: models.Records{testDataAA1234clone, testDataApexMX22bbb},
},
wantMsgs: "CHANGE f.com MX (1 aaa) -> (22 bbb)",
wantChangeRSet: `
ChangeList: len=1
00: Change: verb=CHANGE
key={f.com MX}
old=[1 aaa]
new=[22 bbb]
msg=["CHANGE f.com MX (1 aaa) -> (22 bbb)"]
`,
wantChangeLabel: `
ChangeList: len=1
00: Change: verb=CHANGE
key={f.com }
old=[1 aaa]
new=[22 bbb]
msg=["CHANGE f.com MX (1 aaa) -> (22 bbb)"]
`,
wantChangeRec: `
ChangeList: len=1
00: Change: verb=CHANGE
key={f.com MX}
old=[1 aaa]
new=[22 bbb]
msg=["CHANGE f.com MX (1 aaa) -> (22 bbb)"]
`,
},
{
name: "firsta",
args: args{
origin: origin,
existing: models.Records{testDataAA1234, testDataAMX10a},
desired: models.Records{testDataAA1234clone, testDataAA12345, testDataAMX20b},
},
wantMsgs: `
CREATE laba.f.com A 1.2.3.5
CHANGE laba.f.com MX (10 laba) -> (20 labb)
`,
wantChangeRSet: `
ChangeList: len=2
00: Change: verb=CHANGE
key={laba.f.com A}
old=[1.2.3.4]
new=[1.2.3.4 1.2.3.5]
msg=["CREATE laba.f.com A 1.2.3.5"]
01: Change: verb=CHANGE
key={laba.f.com MX}
old=[10 laba]
new=[20 labb]
msg=["CHANGE laba.f.com MX (10 laba) -> (20 labb)"]
`,
wantChangeLabel: `
ChangeList: len=1
00: Change: verb=CHANGE
key={laba.f.com }
old=[1.2.3.4 10 laba]
new=[1.2.3.4 1.2.3.5 20 labb]
msg=["CREATE laba.f.com A 1.2.3.5" "CHANGE laba.f.com MX (10 laba) -> (20 labb)"]
`,
wantChangeRec: `
ChangeList: len=2
00: Change: verb=CREATE
key={laba.f.com A}
new=[1.2.3.5]
msg=["CREATE laba.f.com A 1.2.3.5"]
01: Change: verb=CHANGE
key={laba.f.com MX}
old=[10 laba]
new=[20 labb]
msg=["CHANGE laba.f.com MX (10 laba) -> (20 labb)"]
`,
},
{
name: "big",
args: args{
origin: origin,
existing: existing,
desired: desired,
},
wantMsgs: `
CREATE laba.f.com A 1.2.3.5
CHANGE laba.f.com MX (10 laba) -> (20 labb)
DELETE labc.f.com CNAME laba
CHANGE labe.f.com A (10.10.10.15) -> (10.10.10.95)
CHANGE labe.f.com A (10.10.10.16) -> (10.10.10.96)
CHANGE labe.f.com A (10.10.10.17) -> (10.10.10.97)
CHANGE labe.f.com A (10.10.10.18) -> (10.10.10.98)
CREATE labf.f.com TXT "foo"
CHANGE labg.f.com NS (10.10.10.17) -> (10.10.10.10)
CHANGE labg.f.com NS (10.10.10.18) -> (10.10.10.97)
DELETE labh.f.com CNAME labd
CREATE labh.f.com A 1.2.3.4
`,
wantChangeRSet: `
ChangeList: len=8
00: Change: verb=CHANGE
key={laba.f.com A}
old=[1.2.3.4]
new=[1.2.3.4 1.2.3.5]
msg=["CREATE laba.f.com A 1.2.3.5"]
01: Change: verb=CHANGE
key={laba.f.com MX}
old=[10 laba]
new=[20 labb]
msg=["CHANGE laba.f.com MX (10 laba) -> (20 labb)"]
02: Change: verb=DELETE
key={labc.f.com CNAME}
old=[laba]
msg=["DELETE labc.f.com CNAME laba"]
03: Change: verb=CHANGE
key={labe.f.com A}
old=[10.10.10.15 10.10.10.16 10.10.10.17 10.10.10.18]
new=[10.10.10.95 10.10.10.96 10.10.10.97 10.10.10.98]
msg=["CHANGE labe.f.com A (10.10.10.15) -> (10.10.10.95)" "CHANGE labe.f.com A (10.10.10.16) -> (10.10.10.96)" "CHANGE labe.f.com A (10.10.10.17) -> (10.10.10.97)" "CHANGE labe.f.com A (10.10.10.18) -> (10.10.10.98)"]
04: Change: verb=CREATE
key={labf.f.com TXT}
new=["foo"]
msg=["CREATE labf.f.com TXT \"foo\""]
05: Change: verb=CHANGE
key={labg.f.com NS}
old=[10.10.10.15 10.10.10.16 10.10.10.17 10.10.10.18]
new=[10.10.10.10 10.10.10.15 10.10.10.16 10.10.10.97]
msg=["CHANGE labg.f.com NS (10.10.10.17) -> (10.10.10.10)" "CHANGE labg.f.com NS (10.10.10.18) -> (10.10.10.97)"]
06: Change: verb=DELETE
key={labh.f.com CNAME}
old=[labd]
msg=["DELETE labh.f.com CNAME labd"]
07: Change: verb=CREATE
key={labh.f.com A}
new=[1.2.3.4]
msg=["CREATE labh.f.com A 1.2.3.4"]
`,
wantChangeLabel: `
ChangeList: len=6
00: Change: verb=CHANGE
key={laba.f.com }
old=[1.2.3.4 10 laba]
new=[1.2.3.4 1.2.3.5 20 labb]
msg=["CREATE laba.f.com A 1.2.3.5" "CHANGE laba.f.com MX (10 laba) -> (20 labb)"]
01: Change: verb=DELETE
key={labc.f.com }
old=[laba]
msg=["DELETE labc.f.com CNAME laba"]
02: Change: verb=CHANGE
key={labe.f.com }
old=[10.10.10.15 10.10.10.16 10.10.10.17 10.10.10.18]
new=[10.10.10.95 10.10.10.96 10.10.10.97 10.10.10.98]
msg=["CHANGE labe.f.com A (10.10.10.15) -> (10.10.10.95)" "CHANGE labe.f.com A (10.10.10.16) -> (10.10.10.96)" "CHANGE labe.f.com A (10.10.10.17) -> (10.10.10.97)" "CHANGE labe.f.com A (10.10.10.18) -> (10.10.10.98)"]
03: Change: verb=CREATE
key={labf.f.com }
new=["foo"]
msg=["CREATE labf.f.com TXT \"foo\""]
04: Change: verb=CHANGE
key={labg.f.com }
old=[10.10.10.15 10.10.10.16 10.10.10.17 10.10.10.18]
new=[10.10.10.10 10.10.10.15 10.10.10.16 10.10.10.97]
msg=["CHANGE labg.f.com NS (10.10.10.17) -> (10.10.10.10)" "CHANGE labg.f.com NS (10.10.10.18) -> (10.10.10.97)"]
05: Change: verb=CHANGE
key={labh.f.com }
old=[labd]
new=[1.2.3.4]
msg=["DELETE labh.f.com CNAME labd" "CREATE labh.f.com A 1.2.3.4"]
`,
wantChangeRec: `
ChangeList: len=12
00: Change: verb=CREATE
key={laba.f.com A}
new=[1.2.3.5]
msg=["CREATE laba.f.com A 1.2.3.5"]
01: Change: verb=CHANGE
key={laba.f.com MX}
old=[10 laba]
new=[20 labb]
msg=["CHANGE laba.f.com MX (10 laba) -> (20 labb)"]
02: Change: verb=DELETE
key={labc.f.com CNAME}
old=[laba]
msg=["DELETE labc.f.com CNAME laba"]
03: Change: verb=CHANGE
key={labe.f.com A}
old=[10.10.10.15]
new=[10.10.10.95]
msg=["CHANGE labe.f.com A (10.10.10.15) -> (10.10.10.95)"]
04: Change: verb=CHANGE
key={labe.f.com A}
old=[10.10.10.16]
new=[10.10.10.96]
msg=["CHANGE labe.f.com A (10.10.10.16) -> (10.10.10.96)"]
05: Change: verb=CHANGE
key={labe.f.com A}
old=[10.10.10.17]
new=[10.10.10.97]
msg=["CHANGE labe.f.com A (10.10.10.17) -> (10.10.10.97)"]
06: Change: verb=CHANGE
key={labe.f.com A}
old=[10.10.10.18]
new=[10.10.10.98]
msg=["CHANGE labe.f.com A (10.10.10.18) -> (10.10.10.98)"]
07: Change: verb=CREATE
key={labf.f.com TXT}
new=["foo"]
msg=["CREATE labf.f.com TXT \"foo\""]
08: Change: verb=CHANGE
key={labg.f.com NS}
old=[10.10.10.17]
new=[10.10.10.10]
msg=["CHANGE labg.f.com NS (10.10.10.17) -> (10.10.10.10)"]
09: Change: verb=CHANGE
key={labg.f.com NS}
old=[10.10.10.18]
new=[10.10.10.97]
msg=["CHANGE labg.f.com NS (10.10.10.18) -> (10.10.10.97)"]
10: Change: verb=DELETE
key={labh.f.com CNAME}
old=[labd]
msg=["DELETE labh.f.com CNAME labd"]
11: Change: verb=CREATE
key={labh.f.com A}
new=[1.2.3.4]
msg=["CREATE labh.f.com A 1.2.3.4"]
`,
},
}
for _, tt := range tests {
// Each "analyze*()" should return the same msgs, but a different ChangeList.
// Sadly the analyze*() functions are destructive to the CompareConfig struct.
// Therefore we have to run NewCompareConfig() each time.
t.Run(tt.name, func(t *testing.T) {
cl := analyzeByRecordSet(NewCompareConfig(tt.args.origin, tt.args.existing, tt.args.desired, tt.args.compFn))
compareMsgs(t, "analyzeByRecordSet", tt.name, "RSet", cl, tt.wantMsgs)
compareCL(t, "analyzeByRecordSet", tt.name, "RSet", cl, tt.wantChangeRSet)
})
t.Run(tt.name, func(t *testing.T) {
cl := analyzeByLabel(NewCompareConfig(tt.args.origin, tt.args.existing, tt.args.desired, tt.args.compFn))
compareMsgs(t, "analyzeByLabel", tt.name, "Label", cl, tt.wantMsgs)
compareCL(t, "analyzeByLabel", tt.name, "Label", cl, tt.wantChangeLabel)
})
t.Run(tt.name, func(t *testing.T) {
cl := analyzeByRecord(NewCompareConfig(tt.args.origin, tt.args.existing, tt.args.desired, tt.args.compFn))
compareMsgs(t, "analyzeByRecord", tt.name, "Rec", cl, tt.wantMsgs)
compareCL(t, "analyzeByRecord", tt.name, "Rec", cl, tt.wantChangeRec)
})
// NB(tlim): There is no analyzeByZone(). ByZone() uses analyzeByRecord().
}
}
func Test_diffTargets(t *testing.T) {
type args struct {
existing []targetConfig
desired []targetConfig
}
tests := []struct {
name string
args args
want ChangeList
}{
{
name: "single",
args: args{
existing: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
},
desired: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
},
},
//want: ,
},
{
name: "add1",
args: args{
existing: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
},
desired: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
{compareable: "10 laba", rec: testDataAMX10a},
},
},
want: ChangeList{
Change{Type: CREATE,
Key: models.RecordKey{NameFQDN: "laba.f.com", Type: "MX"},
New: models.Records{testDataAMX10a},
Msgs: []string{"CREATE laba.f.com MX 10 laba"},
},
},
},
{
name: "del1",
args: args{
existing: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
{compareable: "10 laba", rec: testDataAMX10a},
},
desired: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
},
},
want: ChangeList{
Change{Type: DELETE,
Key: models.RecordKey{NameFQDN: "laba.f.com", Type: "MX"},
Old: models.Records{testDataAMX10a},
Msgs: []string{"DELETE laba.f.com MX 10 laba"},
},
},
},
{
name: "change2nd",
args: args{
existing: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
{compareable: "10 laba", rec: testDataAMX10a},
},
desired: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
{compareable: "20 laba", rec: testDataAMX20b},
},
},
want: ChangeList{
Change{Type: CHANGE,
Key: models.RecordKey{NameFQDN: "laba.f.com", Type: "MX"},
Old: models.Records{testDataAMX10a},
New: models.Records{testDataAMX20b},
Msgs: []string{"CHANGE laba.f.com MX (10 laba) -> (20 labb)"},
},
},
},
{
name: "del2nd",
args: args{
existing: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
{compareable: "5.6.7.8", rec: testDataAA5678},
},
desired: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
},
},
want: ChangeList{
Change{Type: CHANGE,
Key: models.RecordKey{NameFQDN: "laba.f.com", Type: "A"},
Old: models.Records{testDataAA1234, testDataAA5678},
New: models.Records{testDataAA1234},
Msgs: []string{"DELETE laba.f.com A 5.6.7.8"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
//fmt.Printf("DEBUG: Test %02d\n", i)
got := diffTargets(tt.args.existing, tt.args.desired)
d := diff.Diff(strings.TrimSpace(justMsgString(got)), strings.TrimSpace(justMsgString(tt.want)))
if d != "" {
//fmt.Printf("DEBUG: %d %d\n", len(got), len(tt.want))
t.Errorf("diffTargets()\n diff=%s", d)
}
})
}
}
func Test_removeCommon(t *testing.T) {
type args struct {
existing []targetConfig
desired []targetConfig
}
tests := []struct {
name string
args args
want []targetConfig
want1 []targetConfig
}{
{
name: "same",
args: args{
existing: []targetConfig{d0tc},
desired: []targetConfig{d0tc},
},
want: []targetConfig{},
want1: []targetConfig{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, got1 := removeCommon(tt.args.existing, tt.args.desired)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("removeCommon() got = %v, want %v", got, tt.want)
}
if (!(got1 == nil && tt.want1 == nil)) && !reflect.DeepEqual(got1, tt.want1) {
t.Errorf("removeCommon() got1 = %v, want %v", got1, tt.want1)
}
})
}
}
func comparables(s []targetConfig) []string {
var r []string
for _, j := range s {
r = append(r, j.compareable)
}
return r
}
func Test_filterBy(t *testing.T) {
type args struct {
s []targetConfig
m map[string]*targetConfig
}
tests := []struct {
name string
args args
want []targetConfig
}{
{
name: "removeall",
args: args{
s: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
{compareable: "10 laba", rec: testDataAMX10a},
},
m: map[string]*targetConfig{
"1.2.3.4": {compareable: "1.2.3.4", rec: testDataAA1234},
"10 laba": {compareable: "10 laba", rec: testDataAMX10a},
},
},
want: []targetConfig{},
},
{
name: "keepall",
args: args{
s: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
{compareable: "10 laba", rec: testDataAMX10a},
},
m: map[string]*targetConfig{
"nothing": {compareable: "1.2.3.4", rec: testDataAA1234},
"matches": {compareable: "10 laba", rec: testDataAMX10a},
},
},
want: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
{compareable: "10 laba", rec: testDataAMX10a},
},
},
{
name: "keepsome",
args: args{
s: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
{compareable: "10 laba", rec: testDataAMX10a},
},
m: map[string]*targetConfig{
"nothing": {compareable: "1.2.3.4", rec: testDataAA1234},
"10 laba": {compareable: "10 laba", rec: testDataAMX10a},
},
},
want: []targetConfig{
{compareable: "1.2.3.4", rec: testDataAA1234},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := filterBy(tt.args.s, tt.args.m); !reflect.DeepEqual(comparables(got), comparables(tt.want)) {
t.Errorf("filterBy() = %v, want %v", got, tt.want)
}
})
}
}

214
pkg/diff2/compareconfig.go Normal file
View File

@@ -0,0 +1,214 @@
package diff2
import (
"bytes"
"fmt"
"sort"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/StackExchange/dnscontrol/v3/pkg/prettyzone"
)
type ComparableFunc func(*models.RecordConfig) string
type CompareConfig struct {
existing, desired models.Records
ldata []*labelConfig
//
origin string // Domain zone
compareableFunc ComparableFunc
//
labelMap map[string]bool
keyMap map[models.RecordKey]bool
}
type labelConfig struct {
label string
tdata []*rTypeConfig
}
type rTypeConfig struct {
rType string
existingTargets []targetConfig
desiredTargets []targetConfig
existingRecs []*models.RecordConfig
desiredRecs []*models.RecordConfig
}
type targetConfig struct {
compareable string
rec *models.RecordConfig
}
func NewCompareConfig(origin string, existing, desired models.Records, compFn ComparableFunc) *CompareConfig {
cc := &CompareConfig{
existing: existing,
desired: desired,
//
origin: origin,
compareableFunc: compFn,
//
labelMap: map[string]bool{},
keyMap: map[models.RecordKey]bool{},
}
cc.addRecords(existing, true)
cc.addRecords(desired, false)
cc.VerifyCNAMEAssertions()
sort.Slice(cc.ldata, func(i, j int) bool {
return prettyzone.LabelLess(cc.ldata[i].label, cc.ldata[j].label)
})
return cc
}
func (cc *CompareConfig) VerifyCNAMEAssertions() {
// NB(tlim): This can be deleted. This should be probably not possible.
// However, let's keep it around for a few iterations to be paranoid.
// 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
// careful with changes at a label that involve a CNAME.
// Example 1:
// OLD: a.example.com CNAME b
// NEW: a.example.com A 1.2.3.4
// We must delete the CNAME record THEN create the A record. If we
// blindly create the the A first, most APIs will reply with an error
// because there is already a CNAME at that label.
// Example 2:
// OLD: a.example.com A 1.2.3.4
// NEW: a.example.com CNAME b
// We must delete the A record THEN create the CNAME. If we blindly
// create the CNAME first, most APIs will reply with an error because
// there is already an A record at that label.
//
// To assure that DNS providers don't have to think about this, we order
// the tdata items so that we generate the instructions in the best order.
// In other words:
// If there is a CNAME in existing, it should be in front.
// If there is a CNAME in desired, it should be at the end.
// That said, since we addRecords existing first, and desired last, the data
// should already be in the right order.
for _, ld := range cc.ldata {
for j, td := range ld.tdata {
if td.rType == "CNAME" {
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)
if j != highest(ld.tdata) {
panic("should not happen: (CNAME not in last position)")
}
}
}
}
}
}
func (cc *CompareConfig) String() string {
var buf bytes.Buffer
b := &buf
fmt.Fprintf(b, "ldata:\n")
for i, ld := range cc.ldata {
fmt.Fprintf(b, " ldata[%02d]: %s\n", i, ld.label)
for j, t := range ld.tdata {
fmt.Fprintf(b, " tdata[%d]: %q e(%d, %d) d(%d, %d)\n", j, t.rType,
len(t.existingTargets),
len(t.existingRecs),
len(t.desiredTargets),
len(t.desiredRecs),
)
}
}
fmt.Fprintf(b, "labelMap: len=%d %v\n", len(cc.labelMap), cc.labelMap)
fmt.Fprintf(b, "keyMap: len=%d %v\n", len(cc.keyMap), cc.keyMap)
fmt.Fprintf(b, "existing: %q\n", cc.existing)
fmt.Fprintf(b, "desired: %q\n", cc.desired)
fmt.Fprintf(b, "origin: %v\n", cc.origin)
fmt.Fprintf(b, "compFn: %v\n", cc.compareableFunc)
return b.String()
}
func comparable(rc *models.RecordConfig, f func(*models.RecordConfig) string) string {
if f == nil {
return rc.ToDiffable()
}
return rc.ToDiffable() + " " + f(rc)
}
func (cc *CompareConfig) addRecords(recs models.Records, storeInExisting bool) {
// Sort, because sorted data is easier to work with.
// NB(tlim): The actual sort order doesn't matter as long as all the records
// of the same label+rtype are adjacent. We use PrettySort because it works,
// has been extensively tested, and assures that the ChangeList will be a
// pretty order.
//for _, rec := range recs {
z := prettyzone.PrettySort(recs, cc.origin, 0, nil)
for _, rec := range z.Records {
label := rec.NameFQDN
rtype := rec.Type
comp := comparable(rec, cc.compareableFunc)
//fmt.Printf("DEBUG addRecords rec=%v:%v es=%v comp=%v\n", label, rtype, storeInExisting, comp)
//fmt.Printf("BEFORE L: %v\n", len(cc.ldata))
// 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
break
}
}
}
// 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
// find rtype in tdata:
for k, v := range cc.ldata[labelIdx].tdata {
if v.rType == rtype {
rtIdx = k
break
}
}
//fmt.Printf("DEBUG: found rtype=%v at index %d\n", rtype, rtIdx)
// Now it is safe to add/modify the records.
//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})
} 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})
}
//fmt.Printf("AFTER L: %v\n", len(cc.ldata))
//fmt.Printf("AFTER E/D: %v/%v\n", len(td.existingRecs), len(td.desiredRecs))
//fmt.Printf("\n")
}
}

View File

@@ -0,0 +1,214 @@
package diff2
import (
"encoding/json"
"strings"
"testing"
"github.com/StackExchange/dnscontrol/v3/models"
"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
existing models.Records
desired models.Records
compFn ComparableFunc
}
tests := []struct {
name string
args args
want string
}{
{
name: "one",
args: args{
origin: "f.com",
existing: models.Records{testDataAA1234},
desired: models.Records{testDataAA1234clone},
compFn: nil,
},
want: `
ldata:
ldata[00]: laba.f.com
tdata[0]: "A" e(1, 1) d(1, 1)
labelMap: len=1 map[laba.f.com:true]
keyMap: len=1 map[{laba.f.com A}:true]
existing: ["1.2.3.4"]
desired: ["1.2.3.4"]
origin: f.com
compFn: <nil>
`,
},
{
name: "cnameAdd",
args: args{
origin: "f.com",
existing: models.Records{e11mx, d12},
desired: models.Records{e11},
compFn: nil,
},
want: `
ldata:
ldata[00]: labh.f.com
tdata[0]: "A" e(1, 1) d(0, 0)
tdata[1]: "MX" e(1, 1) d(0, 0)
tdata[2]: "CNAME" e(0, 0) d(1, 1)
labelMap: len=1 map[labh.f.com:true]
keyMap: len=3 map[{labh.f.com A}:true {labh.f.com CNAME}:true {labh.f.com MX}:true]
existing: ["22 ttt" "1.2.3.4"]
desired: ["labd"]
origin: f.com
compFn: <nil>
`,
},
{
name: "cnameDel",
args: args{
origin: "f.com",
existing: models.Records{e11},
desired: models.Records{d12, e11mx},
compFn: nil,
},
want: `
ldata:
ldata[00]: labh.f.com
tdata[0]: "CNAME" e(1, 1) d(0, 0)
tdata[1]: "A" e(0, 0) d(1, 1)
tdata[2]: "MX" e(0, 0) d(1, 1)
labelMap: len=1 map[labh.f.com:true]
keyMap: len=3 map[{labh.f.com A}:true {labh.f.com CNAME}:true {labh.f.com MX}:true]
existing: ["labd"]
desired: ["1.2.3.4" "22 ttt"]
origin: f.com
compFn: <nil>
`,
},
{
name: "two",
args: args{
origin: "f.com",
existing: models.Records{testDataAA1234, testDataCCa},
desired: models.Records{testDataAA1234clone},
compFn: nil,
},
want: `
ldata:
ldata[00]: laba.f.com
tdata[0]: "A" e(1, 1) d(1, 1)
ldata[01]: labc.f.com
tdata[0]: "CNAME" e(1, 1) d(0, 0)
labelMap: len=2 map[laba.f.com:true labc.f.com:true]
keyMap: len=2 map[{laba.f.com A}:true {labc.f.com CNAME}:true]
existing: ["1.2.3.4" "laba"]
desired: ["1.2.3.4"]
origin: f.com
compFn: <nil>
`,
},
{
name: "apex",
args: args{
origin: "f.com",
existing: models.Records{testDataAA1234, testDataApexMX1aaa},
desired: models.Records{testDataAA1234clone, testDataApexMX22bbb},
compFn: nil,
},
want: `
ldata:
ldata[00]: f.com
tdata[0]: "MX" e(1, 1) d(1, 1)
ldata[01]: laba.f.com
tdata[0]: "A" e(1, 1) d(1, 1)
labelMap: len=2 map[f.com:true laba.f.com:true]
keyMap: len=2 map[{f.com MX}:true {laba.f.com A}:true]
existing: ["1.2.3.4" "1 aaa"]
desired: ["1.2.3.4" "22 bbb"]
origin: f.com
compFn: <nil>
`,
},
{
name: "many",
args: args{
origin: "f.com",
existing: models.Records{testDataAA1234, testDataAMX10a, testDataCCa, testDataEA15},
desired: models.Records{testDataAA1234clone, testDataAA12345, testDataAMX20b, d3, d4},
compFn: nil,
},
want: `
ldata:
ldata[00]: laba.f.com
tdata[0]: "A" e(1, 1) d(2, 2)
tdata[1]: "MX" e(1, 1) d(1, 1)
ldata[01]: labc.f.com
tdata[0]: "CNAME" e(1, 1) d(0, 0)
ldata[02]: labe.f.com
tdata[0]: "A" e(1, 1) d(2, 2)
labelMap: len=3 map[laba.f.com:true labc.f.com:true labe.f.com:true]
keyMap: len=4 map[{laba.f.com A}:true {laba.f.com MX}:true {labc.f.com CNAME}:true {labe.f.com A}:true]
existing: ["1.2.3.4" "10 laba" "laba" "10.10.10.15"]
desired: ["1.2.3.4" "1.2.3.5" "20 labb" "10.10.10.95" "10.10.10.96"]
origin: f.com
compFn: <nil>
`,
},
{
name: "all",
args: args{
origin: "f.com",
existing: models.Records{testDataAA1234, testDataAMX10a, testDataCCa, testDataEA15, e4, e5, e6, e7, e8, e9, e10, e11},
desired: models.Records{testDataAA1234clone, testDataAA12345, testDataAMX20b, d3, d4, d5, d6, d7, d8, d9, d10, d11, d12},
compFn: nil,
},
want: `
ldata:
ldata[00]: laba.f.com
tdata[0]: "A" e(1, 1) d(2, 2)
tdata[1]: "MX" e(1, 1) d(1, 1)
ldata[01]: labc.f.com
tdata[0]: "CNAME" e(1, 1) d(0, 0)
ldata[02]: labe.f.com
tdata[0]: "A" e(4, 4) d(4, 4)
ldata[03]: labf.f.com
tdata[0]: "TXT" e(0, 0) d(1, 1)
ldata[04]: labg.f.com
tdata[0]: "NS" e(4, 4) d(4, 4)
ldata[05]: labh.f.com
tdata[0]: "CNAME" e(1, 1) d(0, 0)
tdata[1]: "A" e(0, 0) d(1, 1)
labelMap: len=6 map[laba.f.com:true labc.f.com:true labe.f.com:true labf.f.com:true labg.f.com:true labh.f.com:true]
keyMap: len=8 map[{laba.f.com A}:true {laba.f.com MX}:true {labc.f.com CNAME}:true {labe.f.com A}:true {labf.f.com TXT}:true {labg.f.com NS}:true {labh.f.com A}:true {labh.f.com CNAME}:true]
existing: ["1.2.3.4" "10 laba" "laba" "10.10.10.15" "10.10.10.16" "10.10.10.17" "10.10.10.18" "10.10.10.15" "10.10.10.16" "10.10.10.17" "10.10.10.18" "labd"]
desired: ["1.2.3.4" "1.2.3.5" "20 labb" "10.10.10.95" "10.10.10.96" "10.10.10.97" "10.10.10.98" "\"foo\"" "10.10.10.10" "10.10.10.15" "10.10.10.16" "10.10.10.97" "1.2.3.4"]
origin: f.com
compFn: <nil>
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cc := NewCompareConfig(tt.args.origin, tt.args.existing, tt.args.desired, tt.args.compFn)
got := strings.TrimSpace(cc.String())
tt.want = strings.TrimSpace(tt.want)
if got != tt.want {
d := diff.Diff(got, tt.want)
t.Errorf("NewCompareConfig() = \n%s\n", d)
}
})
}
}

185
pkg/diff2/diff2.go Normal file
View File

@@ -0,0 +1,185 @@
package diff2
//go:generate stringer -type=Verb
// This module provides functions that "diff" the existing records
// against the desired records.
import (
"bytes"
"fmt"
"github.com/StackExchange/dnscontrol/v3/models"
)
type Verb int
const (
_ Verb = iota // Skip the first value of 0
CREATE // Create a record/recordset/label where none existed before.
CHANGE // Change existing record/recordset/label
DELETE // Delete existing record/recordset/label
)
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
}
// ByRecordSet takes two lists of records (existing and desired) and
// returns instructions for turning existing into desired.
//
// Use this with DNS providers whose API updates one recordset at a
// time. A recordset is all the records of a particular type at a
// label. For example, if www.example.com has 3 A records and a TXT
// record, if A records are added, changed, or removed, the API takes
// www.example.com, A, and a list of all the desired IP addresses.
//
// 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
}
// ByLabel takes two lists of records (existing and desired) and
// returns instructions for turning existing into desired.
//
// Use this with DNS providers whose API updates one label at a
// time. That is, updates are done by sending a list of DNS records
// to be served at a particular label, or the label itself is deleted.
//
// 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
}
// ByRecord takes two lists of records (existing and desired) and
// returns instructions for turning existing into desired.
//
// Use this with DNS providers whose API updates one record at a time.
//
// 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
}
// ByZone takes two lists of records (existing and desired) and
// returns text one would output to users describing the change.
//
// 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
// zone is uploaded.
//
// The user should see a list of changes as if individual records were
// updated.
//
// The caller of this function should:
//
// 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
// }
//
// 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()
if err != nil {
return nil, false, err
}
cc := NewCompareConfig(dc.Name, existing, desired, compFunc)
instructions := analyzeByRecord(cc)
instructions = processPurge(instructions, !dc.KeepUnknown)
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
fmt.Fprintf(b, "Change: verb=%v\n", c.Type)
fmt.Fprintf(b, " key=%v\n", c.Key)
if len(c.Old) != 0 {
fmt.Fprintf(b, " old=%v\n", c.Old)
}
if len(c.New) != 0 {
fmt.Fprintf(b, " new=%v\n", c.New)
}
fmt.Fprintf(b, " msg=%q\n", c.Msgs)
return b.String()
}
func (cl ChangeList) String() string {
var buf bytes.Buffer
b := &buf
fmt.Fprintf(b, "ChangeList: len=%d\n", len(cl))
for i, j := range cl {
fmt.Fprintf(b, "%02d: %s", i, j)
}
return b.String()
}

45
pkg/diff2/groupsort.go Normal file
View File

@@ -0,0 +1,45 @@
package diff2
import (
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/StackExchange/dnscontrol/v3/pkg/prettyzone"
)
type recset struct {
Key models.RecordKey
Recs []*models.RecordConfig
}
func groupbyRSet(recs models.Records, origin string) []recset {
if len(recs) == 0 {
return nil
}
// 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.
pretty := prettyzone.PrettySort(recs, origin, 0, nil)
recs = pretty.Records
var result []recset
var acc []*models.RecordConfig
// Do the first element
prevkey := recs[0].Key()
acc = append(acc, recs[0])
for i := 1; i < len(recs); i++ {
curkey := recs[i].Key()
if prevkey == curkey { // A run of equal keys.
acc = append(acc, recs[i])
} else { // New key. Append old data to result and start new acc.
result = append(result, recset{Key: prevkey, Recs: acc})
acc = []*models.RecordConfig{recs[i]}
}
prevkey = curkey
}
result = append(result, recset{Key: prevkey, Recs: acc}) // The remainder
return result
}

View File

@@ -0,0 +1,45 @@
package diff2
import (
"reflect"
"testing"
"github.com/StackExchange/dnscontrol/v3/models"
)
func makeRec(label, rtype, content string) *models.RecordConfig {
origin := "f.com"
r := models.RecordConfig{}
r.SetLabel(label, origin)
r.PopulateFromString(rtype, content, origin)
return &r
}
func makeRecSet(recs ...*models.RecordConfig) *recset {
result := recset{}
result.Key = recs[0].Key()
result.Recs = append(result.Recs, recs...)
return &result
}
func Test_groupbyRSet(t *testing.T) {
wwwa1 := makeRec("www", "A", "1.1.1.1")
wwwa2 := makeRec("www", "A", "2.2.2.2")
zzza1 := makeRec("zzz", "A", "1.1.0.0")
zzza2 := makeRec("zzz", "A", "2.2.0.0")
wwwmx1 := makeRec("www", "MX", "1 mx1.foo.com.")
wwwmx2 := makeRec("www", "MX", "2 mx2.foo.com.")
zzzmx1 := makeRec("zzz", "MX", "1 mx.foo.com.")
orig := models.Records{wwwa1, wwwa2, zzza1, zzza2, wwwmx1, wwwmx2, zzzmx1}
wantResult := []recset{
*makeRecSet(wwwa1, wwwa2),
*makeRecSet(wwwmx1, wwwmx2),
*makeRecSet(zzza1, zzza2),
*makeRecSet(zzzmx1),
}
t.Run("afew", func(t *testing.T) {
if gotResult := groupbyRSet(orig, "f.com"); !reflect.DeepEqual(gotResult, wantResult) {
t.Errorf("groupbyRSet() = %v, want %v", gotResult, wantResult)
}
})
}

7
pkg/diff2/highest.go Normal file
View File

@@ -0,0 +1,7 @@
package diff2
// Return the highest valid index for an array. The equiv of len(s)-1, but with
// less likelihood that you'll commit an off-by-one error.
func highest[S ~[]T, T any](s S) int {
return len(s) - 1
}

10
pkg/diff2/min.go Normal file
View File

@@ -0,0 +1,10 @@
package diff2
import "golang.org/x/exp/constraints"
func min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}

22
pkg/diff2/nopurge.go Normal file
View File

@@ -0,0 +1,22 @@
package diff2
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.
newinstructions := make(ChangeList, 0, len(instructions))
for _, j := range instructions {
if j.Type == DELETE {
continue
}
newinstructions = append(newinstructions, j)
}
return newinstructions
}

198
pkg/diff2/notes.txt Normal file
View File

@@ -0,0 +1,198 @@
EXISTING:
laba A 1.2.3.4 [0]
laba MX 10 laba [1]
labc CNAME laba [2]
labe A 10.10.10.15 [3]
labe A 10.10.10.16 [4]
labe A 10.10.10.17 [5]
labe A 10.10.10.18 [6]
labg NS 10.10.10.15 [7]
labg NS 10.10.10.16 [8]
labg NS 10.10.10.17 [9]
labg NS 10.10.10.18 [10]
labh CNAME labd [11]
DESIRED:
laba A 1.2.3.4 [0']
laba A 1.2.3.5 [1']
laba MX 20 labb [2']
labe A 10.10.10.95 [3']
labe A 10.10.10.96 [4']
labe A 10.10.10.97 [5']
labe A 10.10.10.98 [6']
labf TXT "foo" [7']
labg NS 10.10.10.10 [8']
labg NS 10.10.10.15 [9']
labg NS 10.10.10.16 [10']
labg NS 10.10.10.97 [11']
labh A 1.2.3.4 [12']
ByRRSet:
[] laba:A CHANGE NewSet: { [0], [1'] } (ByRecords needs: Old [0] )
[] laba:MX CHANGE NewSet: { [2'] } (ByLabel needs: Old: [2])
[] labc:CNAME DELETE Old: { [2 ] }
[] labe:A CHANGE NewSet: { [3'], [4'], [5'], [6'] }
[] labf:TXT CHANGE NewSet: { [7'] }
[] labg:NS CHANGE NewSet: { [7] [8] [8'] [11'] }
[] labh:CNAME DELETE Old { [11] }
[] labh:A CREATE NewSet: { [12'] }
ByRecord:
CREATE [1']
CHANGE [1] [2']
DELETE [2]
CHANGE [3] [3']
CHANGE [4] [4']
CHANGE [5] [5']
CHANGE [6] [6']
CREATE [7']
CREATE [8']
CHANGE [10] [11']
DELETE [11]
CREATE [12']
ByLabel: (take ByRRSet gather all CHANGES)
laba CHANGE NewSet: { [0'], [1'], [2'] }
labc DELETE Old: { [2] }
labe CHANGE New: { [3'], [4'], [5'], [6'] }
labf CREATE New: { [7'] }
labg CHANGE NewSet: { [7] [8] [8'] [11'] }
labh DELETE Old { [11] }
labh CREATE NewSet: { [12'] }
By Record:
rewrite as triples: FQDN+TYPE, TARGET, RC
byRecord:
group-by key=FQDN+TYPE, use targets to make add/change/delete for each record.
byRSet:
group-by key=FQDN+TYPE, use targets to make add/change/delete for each record.
for each key:
if both have this key:
IF targets are the same, skip.
Else generate CHANGE for KEY:
New = Recs from desired.
Msgs = The msgs from targetdiff(e.Recs, d.Recs)
byLabel:
group-by key=FQDN, use type+targets to make add/change/delete for each record.
rewrite as triples: FQDN {, TYPE, TARGET, RC
type CompareConfig struct {
existing, desired models.Records
ldata: []LabelConfig
}
type ByLabelConfig struct {
label string
tdata: []ByRTypeConfig
}
type ByRTypeConfig struct {
rtype string
existing: []TargetConfig
desired: []TargetConfig
existingRecs: []*models.RecordConfig
desiredRecs: []*models.RecordConfig
}
type TargetConfig struct {
compareable string
rec *model.RecordConfig
}
func highest[S ~[]T, T any](s S) int {
return len(s) - 1
}
populate CompareConfig.
for rec := range desired {
label = FILL
rtype = FILL
comp = FILL
cc.labelMap[label] = &rc
cc.keyMap[key] = &rc
if not seen label:
append cc.ldata ByLabelConfig{}
labelIdx = last(cc.ldata)
if not seen key:
append cc.ldata[labelIdx].tdata ByRTypeConfig{}
rtIdx = last(cc.ldata[labelIdx].tdata)
cc.ldata[labelIdx].label = label
cc.ldata[labelIdx].tdata[rtIdx].rtype = rtype
cc.ldata[labelIdx].tdata[rtIdx].existing[append].comparable = comp
cc.ldata[labelIdx].tdata[rtIdx].existing[append].rec = &rc
}
ByRSet:
func traverse(cc CompareConfig) {
for label := range cc.data {
for rtype := range label.data {
Msgs := genmsgs(rtype.existing, rtype.desired)
if no Msgs, continue
if len(rtype.existing) = 0 {
yield create(label, rtype, rtype.desiredRecs, Msgs)
} else if len(rtype.desired) = 0 {
yield delete(label, rtype, rtype.existingRecs, Msgs)
} else { // equal
yield change(label, rtype, rtype.desiredRecs, Msgs)
}
}
}
byLabel:
func traverse(cc CompareConfig) {
for label := range cc.data {
initialize msgs, desiredRecords
anyExisting = false
for rtype := range label.data {
accumulate Msgs := genmsgs(rtype.existing, rtype.desired)
if Msgs (i.e. there were changes) {
accumulate AllDesired := rtype.desiredRecs
if len(rtype.existing) != 0 {
anyExisting = true
}
}
}
if there are Msgs:
if len(AllDesired) = 0 {
yield delete(label)
} else if countAllExisting == 0 {
yield create(label, AllDesired)
} else {
yield change(label, AllDesired)
}
}
}
ByRecord:
func traverse(cc CompareConfig) {
for label := range cc.data {
for rtype := range label.data {
create, change, delete := difftargets(rtype.existing, rtype.desired)
yield creates, changes, deletes
}
}
}
Byzone:
func traverse(cc CompareConfig) {
for label := range cc.data {
for rtype := range label.data {
Msgs := genmsgs(rtype.existing, rtype.desired)
accumulate Msgs
}
}
if len(accumMsgs) == 0 {
return nil, FirstMsg
} else {
return desired, Msgs
}
}

129
pkg/diff2/unmanaged.go Normal file
View File

@@ -0,0 +1,129 @@
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
}

147
pkg/diff2/unmanaged_test.go Normal file
View File

@@ -0,0 +1,147 @@
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)
}
})
}
}

26
pkg/diff2/verb_string.go Normal file
View File

@@ -0,0 +1,26 @@
// Code generated by "stringer -type=Verb"; DO NOT EDIT.
package diff2
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[CREATE-1]
_ = x[CHANGE-2]
_ = x[DELETE-3]
}
const _Verb_name = "CREATECHANGEDELETE"
var _Verb_index = [...]uint8{0, 6, 12, 18}
func (i Verb) String() string {
i -= 1
if i < 0 || i >= Verb(len(_Verb_index)-1) {
return "Verb(" + strconv.FormatInt(int64(i+1), 10) + ")"
}
return _Verb_name[_Verb_index[i]:_Verb_index[i+1]]
}