github.com/teknogeek/dnscontrol/v2@v2.10.1-0.20200227202244-ae299b55ba42/providers/diff/diff.go (about)

     1  package diff
     2  
     3  import (
     4  	"fmt"
     5  	"sort"
     6  
     7  	"github.com/gobwas/glob"
     8  
     9  	"github.com/StackExchange/dnscontrol/v2/models"
    10  	"github.com/StackExchange/dnscontrol/v2/pkg/printer"
    11  )
    12  
    13  // Correlation stores a difference between two domains.
    14  type Correlation struct {
    15  	d        *differ
    16  	Existing *models.RecordConfig
    17  	Desired  *models.RecordConfig
    18  }
    19  
    20  // Changeset stores many Correlation.
    21  type Changeset []Correlation
    22  
    23  // Differ is an interface for computing the difference between two zones.
    24  type Differ interface {
    25  	// IncrementalDiff performs a diff on a record-by-record basis, and returns a sets for which records need to be created, deleted, or modified.
    26  	IncrementalDiff(existing []*models.RecordConfig) (unchanged, create, toDelete, modify Changeset)
    27  	// ChangedGroups performs a diff more appropriate for providers with a "RecordSet" model, where all records with the same name and type are grouped.
    28  	// 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.
    29  	ChangedGroups(existing []*models.RecordConfig) map[models.RecordKey][]string
    30  }
    31  
    32  // New is a constructor for a Differ.
    33  func New(dc *models.DomainConfig, extraValues ...func(*models.RecordConfig) map[string]string) Differ {
    34  	return &differ{
    35  		dc:          dc,
    36  		extraValues: extraValues,
    37  
    38  		// compile IGNORE glob patterns
    39  		compiledIgnoredLabels: compileIgnoredLabels(dc.IgnoredLabels),
    40  	}
    41  }
    42  
    43  type differ struct {
    44  	dc          *models.DomainConfig
    45  	extraValues []func(*models.RecordConfig) map[string]string
    46  
    47  	compiledIgnoredLabels []glob.Glob
    48  }
    49  
    50  // get normalized content for record. target, ttl, mxprio, and specified metadata
    51  func (d *differ) content(r *models.RecordConfig) string {
    52  	// NB(tlim): This function will eventually be replaced by calling
    53  	// r.GetTargetDiffable().  In the meanwhile, this function compares
    54  	// its output with r.GetTargetDiffable() to make sure the same
    55  	// results are generated.  Once we have confidence, this function will go away.
    56  	content := fmt.Sprintf("%v ttl=%d", r.GetTargetCombined(), r.TTL)
    57  	if r.Type == "SOA" {
    58  		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
    59  	}
    60  	var allMaps []map[string]string
    61  	for _, f := range d.extraValues {
    62  		// sort the extra values map keys to perform a deterministic
    63  		// comparison since Golang maps iteration order is not guaranteed
    64  		valueMap := f(r)
    65  		allMaps = append(allMaps, valueMap)
    66  		keys := make([]string, 0)
    67  		for k := range valueMap {
    68  			keys = append(keys, k)
    69  		}
    70  		sort.Strings(keys)
    71  		for _, k := range keys {
    72  			v := valueMap[k]
    73  			content += fmt.Sprintf(" %s=%s", k, v)
    74  		}
    75  	}
    76  	control := r.ToDiffable(allMaps...)
    77  	if control != content {
    78  		fmt.Printf("CONTROL=%q CONTENT=%q\n", control, content)
    79  		panic("OOPS! control != content")
    80  	}
    81  	return content
    82  }
    83  
    84  func (d *differ) IncrementalDiff(existing []*models.RecordConfig) (unchanged, create, toDelete, modify Changeset) {
    85  	unchanged = Changeset{}
    86  	create = Changeset{}
    87  	toDelete = Changeset{}
    88  	modify = Changeset{}
    89  	desired := d.dc.Records
    90  
    91  	// sort existing and desired by name
    92  
    93  	existingByNameAndType := map[models.RecordKey][]*models.RecordConfig{}
    94  	desiredByNameAndType := map[models.RecordKey][]*models.RecordConfig{}
    95  	for _, e := range existing {
    96  		if d.matchIgnored(e.GetLabel()) {
    97  			printer.Debugf("Ignoring record %s %s due to IGNORE\n", e.GetLabel(), e.Type)
    98  		} else {
    99  			k := e.Key()
   100  			existingByNameAndType[k] = append(existingByNameAndType[k], e)
   101  		}
   102  	}
   103  	for _, dr := range desired {
   104  		if d.matchIgnored(dr.GetLabel()) {
   105  			panic(fmt.Sprintf("Trying to update/add IGNOREd record: %s %s", dr.GetLabel(), dr.Type))
   106  		} else {
   107  			k := dr.Key()
   108  			desiredByNameAndType[k] = append(desiredByNameAndType[k], dr)
   109  		}
   110  	}
   111  	// if NO_PURGE is set, just remove anything that is only in existing.
   112  	if d.dc.KeepUnknown {
   113  		for k := range existingByNameAndType {
   114  			if _, ok := desiredByNameAndType[k]; !ok {
   115  				printer.Debugf("Ignoring record set %s %s due to NO_PURGE\n", k.Type, k.NameFQDN)
   116  				delete(existingByNameAndType, k)
   117  			}
   118  		}
   119  	}
   120  	// Look through existing records. This will give us changes and deletions and some additions.
   121  	// Each iteration is only for a single type/name record set
   122  	for key, existingRecords := range existingByNameAndType {
   123  		desiredRecords := desiredByNameAndType[key]
   124  
   125  		// Very first, get rid of any identical records. Easy.
   126  		for i := len(existingRecords) - 1; i >= 0; i-- {
   127  			ex := existingRecords[i]
   128  			for j, de := range desiredRecords {
   129  				if d.content(de) != d.content(ex) {
   130  					continue
   131  				}
   132  				unchanged = append(unchanged, Correlation{d, ex, de})
   133  				existingRecords = existingRecords[:i+copy(existingRecords[i:], existingRecords[i+1:])]
   134  				desiredRecords = desiredRecords[:j+copy(desiredRecords[j:], desiredRecords[j+1:])]
   135  				break
   136  			}
   137  		}
   138  
   139  		// Next, match by target. This will give the most natural modifications.
   140  		for i := len(existingRecords) - 1; i >= 0; i-- {
   141  			ex := existingRecords[i]
   142  			for j, de := range desiredRecords {
   143  				if de.GetTargetField() == ex.GetTargetField() {
   144  					// two records share a target, but different content (ttl or metadata changes)
   145  					modify = append(modify, Correlation{d, ex, de})
   146  					// remove from both slices by index
   147  					existingRecords = existingRecords[:i+copy(existingRecords[i:], existingRecords[i+1:])]
   148  					desiredRecords = desiredRecords[:j+copy(desiredRecords[j:], desiredRecords[j+1:])]
   149  					break
   150  				}
   151  			}
   152  		}
   153  
   154  		desiredLookup := map[string]*models.RecordConfig{}
   155  		existingLookup := map[string]*models.RecordConfig{}
   156  		// build index based on normalized content data
   157  		for _, ex := range existingRecords {
   158  			normalized := d.content(ex)
   159  			if existingLookup[normalized] != nil {
   160  				panic(fmt.Sprintf("DUPLICATE E_RECORD FOUND: %s %s", key, normalized))
   161  			}
   162  			existingLookup[normalized] = ex
   163  		}
   164  		for _, de := range desiredRecords {
   165  			normalized := d.content(de)
   166  			if desiredLookup[normalized] != nil {
   167  				panic(fmt.Sprintf("DUPLICATE D_RECORD FOUND: %s %s", key, normalized))
   168  			}
   169  			desiredLookup[normalized] = de
   170  		}
   171  		// if a record is in both, it is unchanged
   172  		for norm, ex := range existingLookup {
   173  			if de, ok := desiredLookup[norm]; ok {
   174  				unchanged = append(unchanged, Correlation{d, ex, de})
   175  				delete(existingLookup, norm)
   176  				delete(desiredLookup, norm)
   177  			}
   178  		}
   179  		// sort records by normalized text. Keeps behaviour deterministic
   180  		existingStrings, desiredStrings := sortedKeys(existingLookup), sortedKeys(desiredLookup)
   181  		// Modifications. Take 1 from each side.
   182  		for len(desiredStrings) > 0 && len(existingStrings) > 0 {
   183  			modify = append(modify, Correlation{d, existingLookup[existingStrings[0]], desiredLookup[desiredStrings[0]]})
   184  			existingStrings = existingStrings[1:]
   185  			desiredStrings = desiredStrings[1:]
   186  		}
   187  		// If desired still has things they are additions
   188  		for _, norm := range desiredStrings {
   189  			rec := desiredLookup[norm]
   190  			create = append(create, Correlation{d, nil, rec})
   191  		}
   192  		// if found , but not desired, delete it
   193  		for _, norm := range existingStrings {
   194  			rec := existingLookup[norm]
   195  			toDelete = append(toDelete, Correlation{d, rec, nil})
   196  		}
   197  		// remove this set from the desired list to indicate we have processed it.
   198  		delete(desiredByNameAndType, key)
   199  	}
   200  
   201  	// any name/type sets not already processed are pure additions
   202  	for name := range existingByNameAndType {
   203  		delete(desiredByNameAndType, name)
   204  	}
   205  	for _, desiredList := range desiredByNameAndType {
   206  		for _, rec := range desiredList {
   207  			create = append(create, Correlation{d, nil, rec})
   208  		}
   209  	}
   210  	return
   211  }
   212  
   213  func (d *differ) ChangedGroups(existing []*models.RecordConfig) map[models.RecordKey][]string {
   214  	changedKeys := map[models.RecordKey][]string{}
   215  	_, create, delete, modify := d.IncrementalDiff(existing)
   216  	for _, c := range create {
   217  		changedKeys[c.Desired.Key()] = append(changedKeys[c.Desired.Key()], c.String())
   218  	}
   219  	for _, d := range delete {
   220  		changedKeys[d.Existing.Key()] = append(changedKeys[d.Existing.Key()], d.String())
   221  	}
   222  	for _, m := range modify {
   223  		changedKeys[m.Desired.Key()] = append(changedKeys[m.Desired.Key()], m.String())
   224  	}
   225  	return changedKeys
   226  }
   227  
   228  // DebugKeyMapMap debug prints the results from ChangedGroups.
   229  func DebugKeyMapMap(note string, m map[models.RecordKey][]string) {
   230  	// The output isn't pretty but it is useful.
   231  	fmt.Println("DEBUG:", note)
   232  
   233  	// Extract the keys
   234  	var keys []models.RecordKey
   235  	for k := range m {
   236  		keys = append(keys, k)
   237  	}
   238  	sort.SliceStable(keys, func(i, j int) bool {
   239  		if keys[i].NameFQDN == keys[j].NameFQDN {
   240  			return keys[i].Type < keys[j].Type
   241  		}
   242  		return keys[i].NameFQDN < keys[j].NameFQDN
   243  	})
   244  
   245  	// Pretty print the map:
   246  	for _, k := range keys {
   247  		fmt.Printf("   %v %v:\n", k.Type, k.NameFQDN)
   248  		for _, s := range m[k] {
   249  			fmt.Printf("      -- %q\n", s)
   250  		}
   251  	}
   252  }
   253  
   254  func (c Correlation) String() string {
   255  	if c.Existing == nil {
   256  		return fmt.Sprintf("CREATE %s %s %s", c.Desired.Type, c.Desired.GetLabelFQDN(), c.d.content(c.Desired))
   257  	}
   258  	if c.Desired == nil {
   259  		return fmt.Sprintf("DELETE %s %s %s", c.Existing.Type, c.Existing.GetLabelFQDN(), c.d.content(c.Existing))
   260  	}
   261  	return fmt.Sprintf("MODIFY %s %s: (%s) -> (%s)", c.Existing.Type, c.Existing.GetLabelFQDN(), c.d.content(c.Existing), c.d.content(c.Desired))
   262  }
   263  
   264  func sortedKeys(m map[string]*models.RecordConfig) []string {
   265  	s := []string{}
   266  	for v := range m {
   267  		s = append(s, v)
   268  	}
   269  	sort.Strings(s)
   270  	return s
   271  }
   272  
   273  func compileIgnoredLabels(ignoredLabels []string) []glob.Glob {
   274  	result := make([]glob.Glob, 0, len(ignoredLabels))
   275  
   276  	for _, tst := range ignoredLabels {
   277  		g, err := glob.Compile(tst, '.')
   278  		if err != nil {
   279  			panic(fmt.Sprintf("Failed to compile IGNORE pattern %q: %v", tst, err))
   280  		}
   281  
   282  		result = append(result, g)
   283  	}
   284  
   285  	return result
   286  }
   287  
   288  func (d *differ) matchIgnored(name string) bool {
   289  	for _, tst := range d.compiledIgnoredLabels {
   290  		if tst.Match(name) {
   291  			return true
   292  		}
   293  	}
   294  	return false
   295  }