From e675c1712376a2f8478d7ee96491cae04e55df6c Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 17:51:19 +0700 Subject: [PATCH] feat(diff): mark records deliberately kept outside the template --- internal/diff/diff.go | 46 +++++++++++++++++++++++++++-- internal/diff/diff_test.go | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/internal/diff/diff.go b/internal/diff/diff.go index e5dd15c..bce7321 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -19,6 +19,10 @@ type RecordDiff struct { 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 @@ -37,7 +41,7 @@ type Changeset struct { func (c Changeset) Actionable() []RecordDiff { var out []RecordDiff for _, d := range c.Diffs { - if d.ReadOnly || d.Kind == InSync { + if d.ReadOnly || d.Custom || d.Kind == InSync { continue } out = append(out, d) @@ -52,7 +56,7 @@ func (c Changeset) Actionable() []RecordDiff { func (c Changeset) Updates() []RecordDiff { var out []RecordDiff for _, d := range c.Diffs { - if d.ReadOnly { + if d.ReadOnly || d.Custom { continue } if d.Kind == Add || d.Kind == Update { @@ -73,7 +77,7 @@ func (c Changeset) Updates() []RecordDiff { func (c Changeset) Prunes() []RecordDiff { var out []RecordDiff for _, d := range c.Diffs { - if d.ReadOnly { + if d.ReadOnly || d.Custom { continue } if d.Kind == Delete { @@ -131,3 +135,39 @@ func index(recs []model.Record) map[string]model.Record { } 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 +} diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go index baf90ac..7b77744 100644 --- a/internal/diff/diff_test.go +++ b/internal/diff/diff_test.go @@ -242,3 +242,62 @@ func TestIndexDedupLastWriteWins(t *testing.T) { t.Fatalf("expected exactly 1 diff for the duplicated key, got %d", count) } } + +func TestMarkCustomOnlyMarksDeletes(t *testing.T) { + template := []model.Record{ + {Type: "A", Name: "www.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, + } + actual := []model.Record{ + {Type: "A", Name: "www.example.com.", TTL: 300, Values: []string{"2.2.2.2"}}, + {Type: "CNAME", Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}}, + } + cs := Diff(template, actual) + // Ключ записи, которую шаблон описывает (Update), и ключ лишней записи (Delete). + cs.MarkCustom([]string{"A www.example.com.", "CNAME admin.example.com."}) + + customs := cs.Customs() + if len(customs) != 1 { + t.Fatalf("expected exactly 1 custom diff, got %d: %+v", len(customs), customs) + } + if customs[0].Key() != "CNAME admin.example.com." { + t.Fatalf("expected the Delete diff to be custom, got %q", customs[0].Key()) + } + // Шаблон побеждает: описанный шаблоном ключ остаётся обычным Update. + updates := cs.Updates() + if len(updates) != 1 || updates[0].Key() != "A www.example.com." { + t.Fatalf("expected the templated record to stay in Updates, got %+v", updates) + } +} + +func TestCustomDiffsLeaveActionableAndPrunes(t *testing.T) { + actual := []model.Record{ + {Type: "CNAME", Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}}, + } + cs := Diff(nil, actual) + if len(cs.Prunes()) != 1 { + t.Fatalf("precondition: expected 1 prune before marking, got %+v", cs.Prunes()) + } + cs.MarkCustom([]string{"CNAME admin.example.com."}) + + if got := cs.Prunes(); len(got) != 0 { + t.Fatalf("expected custom diff to leave Prunes, got %+v", got) + } + if got := cs.Actionable(); len(got) != 0 { + t.Fatalf("expected custom diff to leave Actionable, got %+v", got) + } + if got := cs.Customs(); len(got) != 1 { + t.Fatalf("expected 1 custom diff, got %+v", got) + } +} + +func TestMarkCustomNeverMarksReadOnly(t *testing.T) { + actual := []model.Record{ + {Type: "NS", Name: "example.com.", TTL: 300, Values: []string{"ns1.example.com."}}, + } + cs := Diff(nil, actual) + cs.MarkCustom([]string{"NS example.com."}) + + if got := cs.Customs(); len(got) != 0 { + t.Fatalf("read-only diffs must never become custom, got %+v", got) + } +}