1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00
Files
stackexchange-dnscontrol/providers/diff/diff.go
Tom Limoncelli 9812ecd9ff BIND: Improve SOA serial number handling (#651)
* github.com/miekg/dns
* Greatly simplify the logic for handling serial numbers. Related code was all over the place. Now it is abstracted into one testable method makeSoa. This simplifies code in many other places.
* Update docs/_providers/bind.md: Edit old text. Add SOA description.
* SOA records are now treated like any other record internally. You still can't specify them in dnsconfig.js, but that's by design.
* The URL for issue 491 was wrong in many places
* BIND: Clarify GENERATE_ZONEFILE message
2020-02-23 13:58:49 -05:00

296 lines
9.7 KiB
Go

package diff
import (
"fmt"
"sort"
"github.com/gobwas/glob"
"github.com/StackExchange/dnscontrol/v2/models"
"github.com/StackExchange/dnscontrol/v2/pkg/printer"
)
// Correlation stores a difference between two domains.
type Correlation struct {
d *differ
Existing *models.RecordConfig
Desired *models.RecordConfig
}
// Changeset stores many Correlation.
type Changeset []Correlation
// Differ is an interface for computing the difference between two zones.
type Differ interface {
// IncrementalDiff performs a diff on a record-by-record basis, and returns a sets for which records need to be created, deleted, or modified.
IncrementalDiff(existing []*models.RecordConfig) (unchanged, create, toDelete, modify Changeset)
// ChangedGroups performs a diff more appropriate for providers with a "RecordSet" model, where all records with the same name and type are grouped.
// Individual record changes are often not useful in such scenarios. Instead we return a map of record keys to a list of change descriptions within that group.
ChangedGroups(existing []*models.RecordConfig) map[models.RecordKey][]string
}
// New is a constructor for a Differ.
func New(dc *models.DomainConfig, extraValues ...func(*models.RecordConfig) map[string]string) Differ {
return &differ{
dc: dc,
extraValues: extraValues,
// compile IGNORE glob patterns
compiledIgnoredLabels: compileIgnoredLabels(dc.IgnoredLabels),
}
}
type differ struct {
dc *models.DomainConfig
extraValues []func(*models.RecordConfig) map[string]string
compiledIgnoredLabels []glob.Glob
}
// get normalized content for record. target, ttl, mxprio, and specified metadata
func (d *differ) content(r *models.RecordConfig) string {
// NB(tlim): This function will eventually be replaced by calling
// r.GetTargetDiffable(). In the meanwhile, this function compares
// its output with r.GetTargetDiffable() to make sure the same
// results are generated. Once we have confidence, this function will go away.
content := fmt.Sprintf("%v ttl=%d", r.GetTargetCombined(), r.TTL)
if r.Type == "SOA" {
content = fmt.Sprintf("%s %v %d %d %d %d ttl=%d", r.Target, r.SoaMbox, r.SoaRefresh, r.SoaRetry, r.SoaExpire, r.SoaMinttl, r.TTL) // SoaSerial is not used in comparison
}
var allMaps []map[string]string
for _, f := range d.extraValues {
// sort the extra values map keys to perform a deterministic
// comparison since Golang maps iteration order is not guaranteed
valueMap := f(r)
allMaps = append(allMaps, valueMap)
keys := make([]string, 0)
for k := range valueMap {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := valueMap[k]
content += fmt.Sprintf(" %s=%s", k, v)
}
}
control := r.ToDiffable(allMaps...)
if control != content {
fmt.Printf("CONTROL=%q CONTENT=%q\n", control, content)
panic("OOPS! control != content")
}
return content
}
func (d *differ) IncrementalDiff(existing []*models.RecordConfig) (unchanged, create, toDelete, modify Changeset) {
unchanged = Changeset{}
create = Changeset{}
toDelete = Changeset{}
modify = Changeset{}
desired := d.dc.Records
// sort existing and desired by name
existingByNameAndType := map[models.RecordKey][]*models.RecordConfig{}
desiredByNameAndType := map[models.RecordKey][]*models.RecordConfig{}
for _, e := range existing {
if d.matchIgnored(e.GetLabel()) {
printer.Debugf("Ignoring record %s %s due to IGNORE\n", e.GetLabel(), e.Type)
} else {
k := e.Key()
existingByNameAndType[k] = append(existingByNameAndType[k], e)
}
}
for _, dr := range desired {
if d.matchIgnored(dr.GetLabel()) {
panic(fmt.Sprintf("Trying to update/add IGNOREd record: %s %s", dr.GetLabel(), dr.Type))
} else {
k := dr.Key()
desiredByNameAndType[k] = append(desiredByNameAndType[k], dr)
}
}
// if NO_PURGE is set, just remove anything that is only in existing.
if d.dc.KeepUnknown {
for k := range existingByNameAndType {
if _, ok := desiredByNameAndType[k]; !ok {
printer.Debugf("Ignoring record set %s %s due to NO_PURGE\n", k.Type, k.NameFQDN)
delete(existingByNameAndType, k)
}
}
}
// Look through existing records. This will give us changes and deletions and some additions.
// Each iteration is only for a single type/name record set
for key, existingRecords := range existingByNameAndType {
desiredRecords := desiredByNameAndType[key]
// Very first, get rid of any identical records. Easy.
for i := len(existingRecords) - 1; i >= 0; i-- {
ex := existingRecords[i]
for j, de := range desiredRecords {
if d.content(de) != d.content(ex) {
continue
}
unchanged = append(unchanged, Correlation{d, ex, de})
existingRecords = existingRecords[:i+copy(existingRecords[i:], existingRecords[i+1:])]
desiredRecords = desiredRecords[:j+copy(desiredRecords[j:], desiredRecords[j+1:])]
break
}
}
// Next, match by target. This will give the most natural modifications.
for i := len(existingRecords) - 1; i >= 0; i-- {
ex := existingRecords[i]
for j, de := range desiredRecords {
if de.GetTargetField() == ex.GetTargetField() {
// two records share a target, but different content (ttl or metadata changes)
modify = append(modify, Correlation{d, ex, de})
// remove from both slices by index
existingRecords = existingRecords[:i+copy(existingRecords[i:], existingRecords[i+1:])]
desiredRecords = desiredRecords[:j+copy(desiredRecords[j:], desiredRecords[j+1:])]
break
}
}
}
desiredLookup := map[string]*models.RecordConfig{}
existingLookup := map[string]*models.RecordConfig{}
// build index based on normalized content data
for _, ex := range existingRecords {
normalized := d.content(ex)
if existingLookup[normalized] != nil {
panic(fmt.Sprintf("DUPLICATE E_RECORD FOUND: %s %s", key, normalized))
}
existingLookup[normalized] = ex
}
for _, de := range desiredRecords {
normalized := d.content(de)
if desiredLookup[normalized] != nil {
panic(fmt.Sprintf("DUPLICATE D_RECORD FOUND: %s %s", key, normalized))
}
desiredLookup[normalized] = de
}
// if a record is in both, it is unchanged
for norm, ex := range existingLookup {
if de, ok := desiredLookup[norm]; ok {
unchanged = append(unchanged, Correlation{d, ex, de})
delete(existingLookup, norm)
delete(desiredLookup, norm)
}
}
// sort records by normalized text. Keeps behaviour deterministic
existingStrings, desiredStrings := sortedKeys(existingLookup), sortedKeys(desiredLookup)
// Modifications. Take 1 from each side.
for len(desiredStrings) > 0 && len(existingStrings) > 0 {
modify = append(modify, Correlation{d, existingLookup[existingStrings[0]], desiredLookup[desiredStrings[0]]})
existingStrings = existingStrings[1:]
desiredStrings = desiredStrings[1:]
}
// If desired still has things they are additions
for _, norm := range desiredStrings {
rec := desiredLookup[norm]
create = append(create, Correlation{d, nil, rec})
}
// if found , but not desired, delete it
for _, norm := range existingStrings {
rec := existingLookup[norm]
toDelete = append(toDelete, Correlation{d, rec, nil})
}
// remove this set from the desired list to indicate we have processed it.
delete(desiredByNameAndType, key)
}
// any name/type sets not already processed are pure additions
for name := range existingByNameAndType {
delete(desiredByNameAndType, name)
}
for _, desiredList := range desiredByNameAndType {
for _, rec := range desiredList {
create = append(create, Correlation{d, nil, rec})
}
}
return
}
func (d *differ) ChangedGroups(existing []*models.RecordConfig) map[models.RecordKey][]string {
changedKeys := map[models.RecordKey][]string{}
_, create, delete, modify := d.IncrementalDiff(existing)
for _, c := range create {
changedKeys[c.Desired.Key()] = append(changedKeys[c.Desired.Key()], c.String())
}
for _, d := range delete {
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
}
// DebugKeyMapMap debug prints the results from ChangedGroups.
func DebugKeyMapMap(note string, m map[models.RecordKey][]string) {
// The output isn't pretty but it is useful.
fmt.Println("DEBUG:", note)
// Extract the keys
var keys []models.RecordKey
for k := range m {
keys = append(keys, k)
}
sort.SliceStable(keys, func(i, j int) bool {
if keys[i].NameFQDN == keys[j].NameFQDN {
return keys[i].Type < keys[j].Type
}
return keys[i].NameFQDN < keys[j].NameFQDN
})
// Pretty print the map:
for _, k := range keys {
fmt.Printf(" %v %v:\n", k.Type, k.NameFQDN)
for _, s := range m[k] {
fmt.Printf(" -- %q\n", s)
}
}
}
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))
}
func sortedKeys(m map[string]*models.RecordConfig) []string {
s := []string{}
for v := range m {
s = append(s, v)
}
sort.Strings(s)
return s
}
func compileIgnoredLabels(ignoredLabels []string) []glob.Glob {
result := make([]glob.Glob, 0, len(ignoredLabels))
for _, tst := range ignoredLabels {
g, err := glob.Compile(tst, '.')
if err != nil {
panic(fmt.Sprintf("Failed to compile IGNORE pattern %q: %v", tst, err))
}
result = append(result, g)
}
return result
}
func (d *differ) matchIgnored(name string) bool {
for _, tst := range d.compiledIgnoredLabels {
if tst.Match(name) {
return true
}
}
return false
}