diff --git a/CLAUDE.md b/CLAUDE.md index 01877e1..3b544b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,11 +15,13 @@ make build-all # web + build make docker-up # docker compose: app + postgres (собирает образ) cd web && npm run test -- --run # фронт-тесты (Vitest) +cd web && npx vitest run src/path/File.test.tsx # один фронт-тест +cd web && npm run lint # oxlint cd web && npx tsc --noEmit # проверка типов -cd web && npm run build # прод-сборка фронта +cd web && npm run build # прод-сборка фронта (tsc -b && vite build) ``` -Конфигурация только через env: `DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (base64, декодит в **ровно 32 байта**; `openssl rand -base64 32`), `DNS_AR_LISTEN` (default `:8080`). Миграции (goose) гоняются приложением на старте. +Конфигурация только через env: `DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (base64, декодит в **ровно 32 байта**; `openssl rand -base64 32`), `DNS_AR_LISTEN` (default `:8080`). Миграции (goose) гоняются приложением на старте. Compose-файлы: `docker-compose.yml` (локально), `docker-compose-prod.yml` (прод). `AGENTS.md` — копия этого файла для Codex (untracked): при правках синхронизировать или не коммитить. ## Критичные подводные камни diff --git a/internal/diff/diff.go b/internal/diff/diff.go index bce7321..e8033ab 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -90,6 +90,7 @@ func (c Changeset) Prunes() []RecordDiff { // 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 @@ -120,6 +121,43 @@ func Diff(template, actual []model.Record) Changeset { 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 diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go index 7b77744..115abf3 100644 --- a/internal/diff/diff_test.go +++ b/internal/diff/diff_test.go @@ -161,9 +161,9 @@ func TestPrunesEmptyTemplateAllManagedNoneInUpdates(t *testing.T) { // ReadOnly NS diff (here an Add) is excluded from both — mirroring Actionable(). func TestUpdatesAndPrunesMixedSeparation(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{"9.9.9.9"}}, // update - {Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}}, // add + {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{"9.9.9.9"}}, // update + {Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}}, // add {Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}}, // read-only add } actual := []model.Record{ @@ -301,3 +301,24 @@ func TestMarkCustomNeverMarksReadOnly(t *testing.T) { t.Fatalf("read-only diffs must never become custom, got %+v", got) } } + +// Two template rows at the same name+type are one RRset: values are merged. +func TestDiffMergesTemplateRecordsWithSameKey(t *testing.T) { + tpl := []model.Record{ + {Type: model.MX, Name: "example.com.", TTL: 600, Values: []string{"0 pmg2-mail.example.com."}}, + {Type: model.MX, Name: "example.com.", TTL: 600, Values: []string{"10 pmg1-mail.example.com."}}, + {Type: model.MX, Name: "Example.com.", TTL: 600, Values: []string{"10 pmg1-mail.example.com"}}, // dup value + } + cs := Diff(tpl, nil) + if len(cs.Diffs) != 1 { + t.Fatalf("want 1 diff, got %d", len(cs.Diffs)) + } + d := cs.Diffs[0] + if d.Kind != Add || len(d.Desired.Values) != 2 { + t.Fatalf("want Add with 2 values, got %v %v", d.Kind, d.Desired.Values) + } + actual := []model.Record{{Type: model.MX, Name: "example.com.", TTL: 600, Values: []string{"10 pmg1-mail.example.com.", "0 pmg2-mail.example.com."}}} + if got := Diff(tpl, actual).Diffs[0].Kind; got != InSync { + t.Fatalf("want InSync, got %v", got) + } +}