feat(diff): диф-движок шаблон↔зона с Actionable и ReadOnly для NS/SOA
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||
)
|
||||
|
||||
func find(cs Changeset, key string) *RecordDiff {
|
||||
for i := range cs.Diffs {
|
||||
d := cs.Diffs[i]
|
||||
var r *model.Record
|
||||
if d.Desired != nil {
|
||||
r = d.Desired
|
||||
} else {
|
||||
r = d.Actual
|
||||
}
|
||||
if r.Key() == key {
|
||||
return &cs.Diffs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDiffAddUpdateDeleteInSync(t *testing.T) {
|
||||
tmpl := []model.Record{
|
||||
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync
|
||||
{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}}, // update
|
||||
{Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}}, // add
|
||||
}
|
||||
actual := []model.Record{
|
||||
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
||||
{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"9.9.9.9"}},
|
||||
{Type: model.A, Name: "d.example.com.", TTL: 300, Values: []string{"4.4.4.4"}}, // delete (extra)
|
||||
}
|
||||
cs := Diff(tmpl, actual)
|
||||
|
||||
if d := find(cs, "A a.example.com."); d == nil || d.Kind != InSync {
|
||||
t.Fatalf("a should be InSync, got %+v", d)
|
||||
}
|
||||
if d := find(cs, "A b.example.com."); d == nil || d.Kind != Update {
|
||||
t.Fatalf("b should be Update, got %+v", d)
|
||||
}
|
||||
if d := find(cs, "A c.example.com."); d == nil || d.Kind != Add {
|
||||
t.Fatalf("c should be Add, got %+v", d)
|
||||
}
|
||||
if d := find(cs, "A d.example.com."); d == nil || d.Kind != Delete {
|
||||
t.Fatalf("d should be Delete, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffMarksReadOnlyForNSSOA(t *testing.T) {
|
||||
tmpl := []model.Record{{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}}}
|
||||
actual := []model.Record{{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns9.other.com."}}}
|
||||
cs := Diff(tmpl, actual)
|
||||
d := find(cs, "NS example.com.")
|
||||
if d == nil || d.Kind != Update || !d.ReadOnly {
|
||||
t.Fatalf("NS diff must be Update and ReadOnly, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionableExcludesInSyncAndReadOnly(t *testing.T) {
|
||||
tmpl := []model.Record{
|
||||
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync
|
||||
{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}}, // add
|
||||
{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}}, // read-only add
|
||||
}
|
||||
actual := []model.Record{
|
||||
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
||||
}
|
||||
act := Diff(tmpl, actual).Actionable()
|
||||
if len(act) != 1 || act[0].Name != "b.example.com." {
|
||||
t.Fatalf("only b.example.com. is actionable, got %+v", act)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user