diff --git a/internal/diff/diff.go b/internal/diff/diff.go new file mode 100644 index 0000000..2d1edef --- /dev/null +++ b/internal/diff/diff.go @@ -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 +} diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go new file mode 100644 index 0000000..9771acb --- /dev/null +++ b/internal/diff/diff_test.go @@ -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) + } +}