Files
vasyansk 0b26923586 feat(apply): per-record selection + deletes-before-updates ordering
RecordDiff.Key() gives a stable normalized identifier ("TYPE name.") for
every diff kind, exposed as recordView.Key. ApplyRequest now takes
Updates/Prunes key lists instead of two booleans, so callers can apply a
subset of records. service.Apply builds the applied set with selected
prunes (Delete) added before selected updates (Add/Update) — an
invariant, not an option — since the provider rejects an Add/Update
whose name still conflicts with an existing record (e.g. a CNAME cannot
be created while an A on the same name still exists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
2026-07-05 15:10:01 +07:00

134 lines
4.0 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
}
// 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.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 {
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 {
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 {
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}
}
// 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
}