Two template rows at the same name and type (e.g. two MX) produced two diffs with one key; each PATCH replaced the whole provider RRset, so only the last value survived. Merge values by key before diffing. docs: add lint/single-test commands and compose files to CLAUDE.md Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
212 lines
6.4 KiB
Go
212 lines
6.4 KiB
Go
package diff
|
|
|
|
import "github.com/vasyakrg/dns-autoresolver/internal/model"
|
|
|
|
type ChangeKind string
|
|
|
|
const (
|
|
InSync ChangeKind = "in_sync"
|
|
Add ChangeKind = "add"
|
|
Update ChangeKind = "update"
|
|
Delete ChangeKind = "delete"
|
|
)
|
|
|
|
// RecordDiff describes one RRset's deviation between template and zone.
|
|
type RecordDiff struct {
|
|
Kind ChangeKind
|
|
Type model.RecordType
|
|
Name string
|
|
Desired *model.Record // nil for Delete
|
|
Actual *model.Record // nil for Add
|
|
ReadOnly bool // NS/SOA — shown but never applied
|
|
// Custom marks a record the operator deliberately keeps outside the
|
|
// template: shown in its own section, never counted as drift, never
|
|
// applied. Set by MarkCustom, not by Diff.
|
|
Custom bool
|
|
}
|
|
|
|
// Key is the stable identifier of the RRset this diff targets, normalised the
|
|
// same way as model.Record.Key ("TYPE name."). Used to select individual diffs
|
|
// for a partial apply. Works for every Kind (Delete has no Desired, Add has no
|
|
// Actual) because Type/Name are always populated.
|
|
func (d RecordDiff) Key() string {
|
|
return model.Record{Type: d.Type, Name: d.Name}.Key()
|
|
}
|
|
|
|
type Changeset struct {
|
|
Diffs []RecordDiff
|
|
}
|
|
|
|
// Actionable returns managed diffs that are not in sync.
|
|
func (c Changeset) Actionable() []RecordDiff {
|
|
var out []RecordDiff
|
|
for _, d := range c.Diffs {
|
|
if d.ReadOnly || d.Custom || d.Kind == InSync {
|
|
continue
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Updates returns managed (non-ReadOnly) diffs that add or bring a record in
|
|
// line with the template (Kind == Add or Kind == Update). It never contains
|
|
// Delete diffs, so it is safe for callers to apply automatically without
|
|
// risking data loss from an incomplete or empty template.
|
|
func (c Changeset) Updates() []RecordDiff {
|
|
var out []RecordDiff
|
|
for _, d := range c.Diffs {
|
|
if d.ReadOnly || d.Custom {
|
|
continue
|
|
}
|
|
if d.Kind == Add || d.Kind == Update {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Prunes returns managed (non-ReadOnly) diffs where a record exists in the
|
|
// zone but is absent from the template (Kind == Delete). These are
|
|
// potentially destructive: an incomplete or empty template would surface
|
|
// every managed zone record here. Callers should require explicit
|
|
// confirmation before applying Prunes(), unlike Updates().
|
|
//
|
|
// Updates() and Prunes() partition Actionable(): every diff in Actionable()
|
|
// appears in exactly one of the two.
|
|
func (c Changeset) Prunes() []RecordDiff {
|
|
var out []RecordDiff
|
|
for _, d := range c.Diffs {
|
|
if d.ReadOnly || d.Custom {
|
|
continue
|
|
}
|
|
if d.Kind == Delete {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Diff compares a template against the actual zone records.
|
|
// Records present in the zone but absent from the template yield Delete.
|
|
func Diff(template, actual []model.Record) Changeset {
|
|
template = mergeByKey(template)
|
|
current := index(actual)
|
|
seen := make(map[string]bool, len(template))
|
|
var diffs []RecordDiff
|
|
|
|
for _, t := range template {
|
|
tt := t
|
|
key := tt.Key()
|
|
seen[key] = true
|
|
ro := !tt.Type.Managed()
|
|
if a, ok := current[key]; ok {
|
|
ac := a
|
|
kind := Update
|
|
if tt.Equal(ac) {
|
|
kind = InSync
|
|
}
|
|
diffs = append(diffs, RecordDiff{Kind: kind, Type: tt.Type, Name: tt.Name, Desired: &tt, Actual: &ac, ReadOnly: ro})
|
|
} else {
|
|
diffs = append(diffs, RecordDiff{Kind: Add, Type: tt.Type, Name: tt.Name, Desired: &tt, ReadOnly: ro})
|
|
}
|
|
}
|
|
for _, a := range actual {
|
|
ac := a
|
|
if seen[ac.Key()] {
|
|
continue
|
|
}
|
|
diffs = append(diffs, RecordDiff{Kind: Delete, Type: ac.Type, Name: ac.Name, Actual: &ac, ReadOnly: !ac.Type.Managed()})
|
|
}
|
|
return Changeset{Diffs: diffs}
|
|
}
|
|
|
|
// mergeByKey folds template records sharing a Key() into one RRset whose
|
|
// Values are the union (deduplicated by normalized value) in first-seen order.
|
|
// A template may list several records at the same name+type (two MX rows);
|
|
// the provider models them as ONE RRset, so applying them separately would
|
|
// make each replace the previous. TTL of the first record wins. Order of
|
|
// first occurrences is preserved.
|
|
func mergeByKey(recs []model.Record) []model.Record {
|
|
out := make([]model.Record, 0, len(recs))
|
|
pos := make(map[string]int, len(recs))
|
|
for _, r := range recs {
|
|
k := r.Key()
|
|
i, ok := pos[k]
|
|
if !ok {
|
|
r.Values = append([]string(nil), r.Values...)
|
|
pos[k] = len(out)
|
|
out = append(out, r)
|
|
continue
|
|
}
|
|
have := make(map[string]bool, len(out[i].Values))
|
|
for _, v := range out[i].Values {
|
|
have[normalizeValueOf(out[i].Type, v)] = true
|
|
}
|
|
for _, v := range r.Values {
|
|
nv := normalizeValueOf(r.Type, v)
|
|
if !have[nv] {
|
|
have[nv] = true
|
|
out[i].Values = append(out[i].Values, v)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeValueOf(t model.RecordType, v string) string {
|
|
return model.Record{Type: t, Values: []string{v}}.NormalizedValues()[0]
|
|
}
|
|
|
|
// index builds a lookup of records by Key(). If two records in recs share
|
|
// the same Key() (e.g. a provider returning a duplicate RRset entry), this
|
|
// is a deliberate, documented choice, not an oversight: the LAST record with
|
|
// that Key() wins, since later map assignments overwrite earlier ones for
|
|
// the same key during the loop below. Diff() only ever sees the winning
|
|
// (last) record for a duplicated key; earlier duplicates are silently
|
|
// collapsed. See TestIndexDedupLastWriteWins in diff_test.go, which pins
|
|
// this behavior down.
|
|
func index(recs []model.Record) map[string]model.Record {
|
|
m := make(map[string]model.Record, len(recs))
|
|
for _, r := range recs {
|
|
m[r.Key()] = r
|
|
}
|
|
return m
|
|
}
|
|
|
|
// MarkCustom flags the diffs whose Key() is in keys as Custom. Only
|
|
// Kind == Delete diffs are marked, and never read-only ones: the template
|
|
// wins. As soon as the template starts describing a key, its diff becomes
|
|
// Add/Update/InSync and the stored mark stops having any effect (it is not
|
|
// deleted — the operator may go back to a template without that record).
|
|
func (c *Changeset) MarkCustom(keys []string) {
|
|
if len(keys) == 0 {
|
|
return
|
|
}
|
|
set := make(map[string]bool, len(keys))
|
|
for _, k := range keys {
|
|
set[k] = true
|
|
}
|
|
for i := range c.Diffs {
|
|
d := &c.Diffs[i]
|
|
if d.ReadOnly || d.Kind != Delete {
|
|
continue
|
|
}
|
|
if set[d.Key()] {
|
|
d.Custom = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Customs returns diffs marked by MarkCustom. Disjoint from Updates() and
|
|
// Prunes(), and outside Actionable() — a custom record is never drift.
|
|
func (c Changeset) Customs() []RecordDiff {
|
|
var out []RecordDiff
|
|
for _, d := range c.Diffs {
|
|
if d.Custom {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|