# Шаблоны с плейсхолдером {{domain_name}} Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. **Goal:** Сделать шаблоны переиспользуемыми между доменами через плейсхолдер `{{domain_name}}`: шаблон хранит записи с плейсхолдером, а при diff/apply он материализуется под имя конкретной зоны. Снимок зоны в шаблон авто-параметризуется. **Architecture:** Плейсхолдер `{{domain_name}}` в именах и значениях записей шаблона. Материализация (подстановка имени зоны без завершающей точки) — централизованно в `service.resolve`, поэтому и diff, и apply работают с уже подставленными значениями. Обратная операция (параметризация) применяется при snapshot зоны в шаблон. Новый пакет `internal/tmpl` изолирует обе операции. **Tech Stack:** Go (strings), sqlc-модель (правится вручную — sqlc не установлен), React + Vite. ## Global Constraints - Плейсхолдер: буквально `{{domain_name}}` (константа). Подставляется имя зоны БЕЗ завершающей точки: `strings.TrimSuffix(zoneName, ".")`. Точки в шаблоне — явные (пользователь пишет `_dmarc.{{domain_name}}.`). - Материализация применяется в именах записей И во всех значениях (Values). - Snapshot (`template-from-zone`) авто-параметризует: заменяет вхождения имени зоны на `{{domain_name}}` в именах и значениях. Записи без вхождений (DKIM-ключ, CNAME на внешний хост) остаются как есть. - Материализация — единственная точка истины для diff/apply: НЕ дублировать логику подстановки в провайдере или где-то ещё. - sqlc НЕ установлен: `LoadDomainFull` правится вручную и в `internal/store/queries/domains.sql` (источник), и в `internal/store/db/domains.sql.go` (сгенерированный) — держать синхронно. - Комментарии в Go — на английском; в web — как в окружающих файлах. TDD. НЕ коммитить реальную сборку `internal/web/dist/*`. --- ### Task 1: Backend — пакет tmpl (материализация/параметризация) + zoneName в resolve + snapshot **Files:** - Create: `internal/tmpl/tmpl.go`, `internal/tmpl/tmpl_test.go` - Modify: `internal/service/service.go` (DomainRef.ZoneName + resolve), `internal/store/loader.go` (LoadDomain возвращает ZoneName), `internal/store/queries/domains.sql` + `internal/store/db/domains.sql.go` (LoadDomainFull += zone_name), `internal/api/handlers.go` (handleTemplateFromZone параметризует) - Test: `internal/service/service_test.go`, `internal/api/tenant_test.go` **Interfaces:** - Produces: `tmpl.Placeholder = "{{domain_name}}"`; `tmpl.Materialize(doc dto.TemplateDoc, zoneName string) []model.Record`; `tmpl.Parameterize(recs []model.Record, zoneName string) dto.TemplateDoc`; `service.DomainRef.ZoneName`. - Consumes: `dto.TemplateDoc`, `dto.RecordDTO`, `model.Record`. - [ ] **Step 1: Тесты пакета tmpl** `internal/tmpl/tmpl_test.go`: ```go func TestMaterializeReplacesInNameAndValues(t *testing.T) { doc := dto.TemplateDoc{Records: []dto.RecordDTO{ {Type: "TXT", Name: "_dmarc.{{domain_name}}.", TTL: 600, Values: []string{"v=DMARC1; p=quarantine"}}, {Type: "MX", Name: "{{domain_name}}.", TTL: 600, Values: []string{"0 pmg2-mail.{{domain_name}}."}}, {Type: "TXT", Name: "{{domain_name}}.", TTL: 600, Values: []string{"v=spf1 a:mail.{{domain_name}} ~all"}}, }} recs := tmpl.Materialize(doc, "reconops.ru.") // trailing dot stripped if recs[0].Name != "_dmarc.reconops.ru." { t.Fatalf("name: %q", recs[0].Name) } if recs[1].Values[0] != "0 pmg2-mail.reconops.ru." { t.Fatalf("mx value: %q", recs[1].Values[0]) } if recs[2].Values[0] != "v=spf1 a:mail.reconops.ru ~all" { t.Fatalf("spf value: %q", recs[2].Values[0]) } } func TestParameterizeIsInverseForZoneOccurrences(t *testing.T) { recs := []model.Record{ {Type: "TXT", Name: "_dmarc.reconops.ru.", TTL: 600, Values: []string{"v=DMARC1"}}, {Type: "TXT", Name: "reconops.ru.", TTL: 600, Values: []string{"v=spf1 a:mail.reconops.ru ~all"}}, {Type: "CNAME", Name: "mail.reconops.ru.", TTL: 600, Values: []string{"amail.amega.kz."}}, // external host untouched } doc := tmpl.Parameterize(recs, "reconops.ru.") if doc.Records[0].Name != "_dmarc.{{domain_name}}." { t.Fatalf("name: %q", doc.Records[0].Name) } if doc.Records[1].Values[0] != "v=spf1 a:mail.{{domain_name}} ~all" { t.Fatalf("spf: %q", doc.Records[1].Values[0]) } if doc.Records[2].Values[0] != "amail.amega.kz." { t.Fatalf("external cname value must be untouched: %q", doc.Records[2].Values[0]) } } ``` Run: `go test ./internal/tmpl/...` — Ожидание FAIL (пакета нет). - [ ] **Step 2: Пакет tmpl** `internal/tmpl/tmpl.go`: ```go // Package tmpl renders reusable DNS templates for a concrete zone. // // A template stores records containing the {{domain_name}} placeholder; it is // materialised (placeholder -> zone name, without the trailing dot) just // before diff/apply against a specific domain, and parameterised (zone name // -> placeholder) when snapshotting a live zone into a reusable template. package tmpl import ( "strings" "github.com/vasyakrg/dns-autoresolver/internal/model" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) const Placeholder = "{{domain_name}}" func zoneName(z string) string { return strings.TrimSuffix(z, ".") } // Materialize renders a template's records for the given zone, substituting // {{domain_name}} with the zone name (без завершающей точки) in each record's // Name and every Value. func Materialize(doc dto.TemplateDoc, zone string) []model.Record { z := zoneName(zone) out := make([]model.Record, 0, len(doc.Records)) for _, r := range doc.Records { vals := make([]string, len(r.Values)) for i, v := range r.Values { vals[i] = strings.ReplaceAll(v, Placeholder, z) } out = append(out, model.Record{ Type: model.RecordType(r.Type), Name: strings.ReplaceAll(r.Name, Placeholder, z), TTL: r.TTL, Values: vals, }) } return out } // Parameterize replaces occurrences of the zone name with {{domain_name}} in // record names and values, so a snapshot of a live zone becomes portable. // Records with no zone-name occurrence (DKIM key, external CNAME target) are // left unchanged. func Parameterize(recs []model.Record, zone string) dto.TemplateDoc { z := zoneName(zone) out := dto.TemplateDoc{Records: make([]dto.RecordDTO, 0, len(recs))} for _, r := range recs { vals := make([]string, len(r.Values)) for i, v := range r.Values { vals[i] = strings.ReplaceAll(v, z, Placeholder) } out.Records = append(out.Records, dto.RecordDTO{ Type: string(r.Type), Name: strings.ReplaceAll(r.Name, z, Placeholder), TTL: r.TTL, Values: vals, }) } return out } ``` Run: `go test ./internal/tmpl/...` — Ожидание PASS. - [ ] **Step 3: DomainRef.ZoneName + LoadDomainFull zone_name** `internal/service/service.go` — `DomainRef` += `ZoneName string`. `internal/store/queries/domains.sql` — LoadDomainFull: ```sql -- name: LoadDomainFull :one SELECT d.zone_id, d.zone_name, a.provider, a.secret_enc, t.doc FROM domains d JOIN provider_accounts a ON a.id = d.provider_account_id LEFT JOIN templates t ON t.id = d.template_id WHERE d.id = $1 AND d.project_id = $2; ``` `internal/store/db/domains.sql.go` (sqlc НЕ установлен — правь вручную, синхронно): в `loadDomainFull` const добавь `d.zone_name` в SELECT; в `LoadDomainFullRow` добавь `ZoneName string json:"zone_name"` (после ZoneID); в `Scan(...)` добавь `&i.ZoneName` в правильном порядке (сразу после `&i.ZoneID`). `internal/store/loader.go` — `LoadDomain` возвращает `ZoneName: row.ZoneName` в DomainRef. (LoadZone можно оставить как есть — snapshot берёт zoneName из GetDomain.) - [ ] **Step 4: resolve материализует** `internal/service/service.go` resolve — заменить: ```go cs := diff.Diff(ref.Template.ToModel(), actual) ``` на: ```go cs := diff.Diff(tmpl.Materialize(ref.Template, ref.ZoneName), actual) ``` (импортируй `internal/tmpl`.) Обнови фейки Loader в тестах — LoadDomain должен возвращать ZoneName; добавь тест-кейс, что resolve/Check материализует плейсхолдер (шаблон с `{{domain_name}}` → diff считается против actual с подставленным именем зоны фейкового провайдера). - [ ] **Step 5: snapshot параметризует** `internal/api/handlers.go` `handleTemplateFromZone` — после фильтра managed-записей заменить: ```go doc := dto.FromModel(managed) ``` на: ```go doc := tmpl.Parameterize(managed, dom.ZoneName) ``` (импортируй `internal/tmpl`; `dom.ZoneName` уже есть из GetDomain.) Обнови/добавь тест: snapshot зоны reconops.ru → в созданном шаблоне имена/значения содержат `{{domain_name}}` вместо `reconops.ru` (мок ZoneRecords верни записи с именем зоны, проверь что в CreateTemplate ушёл doc с плейсхолдером). - [ ] **Step 6: Прогон и коммит** Run: `go build ./... && go test ./internal/...`. Ожидание PASS. ```bash git add internal/ git commit -m "feat(tmpl): {{domain_name}} placeholder — materialize on diff/apply, parameterize on snapshot" ``` --- ### Task 2: Frontend — подсказка о плейсхолдере в редакторе шаблонов **Files:** - Modify: `web/src/components/RecordEditor.tsx` (или `web/src/pages/TemplatesPage.tsx` — где логичнее подсказка) - Test: соответствующий *.test.tsx **Interfaces:** только UI-подсказка; поведение diff уже материализовано бэком, значения приходят подставленными. - [ ] **Step 1: Подсказка** В редакторе шаблона (RecordEditor или TemplatesPage, рядом с полями записей) добавь короткую подсказку (FieldDescription / текст), например: «Используйте `{{domain_name}}` в имени или значении — при проверке домена подставится имя его зоны (без завершающей точки).» Стиль — как существующие подсказки на страницах (напр. FieldDescription в AccountsPage). Не добавляй валидацию/трансформацию — плейсхолдер это обычный текст. - [ ] **Step 2: Тест + прогон** Тест: подсказка с текстом про `{{domain_name}}` присутствует в отрендеренном редакторе шаблона. Run: `cd web && npm run test -- --run && npx tsc --noEmit && npm run build`. ```bash cd .. && git checkout internal/web/dist/index.html git add web/src/ git commit -m "feat(web): hint about {{domain_name}} placeholder in template editor" ``` --- ## Итоговая проверка - `go build ./... && go test ./...` — PASS. - `cd web && npm run test -- --run && npm run build` — PASS. - Ручная: шаблон с `{{domain_name}}` привязать к домену A и B → дифф каждого показывает подстановку имени соответствующей зоны; snapshot зоны создаёт шаблон с `{{domain_name}}` вместо имени зоны; шаблон переиспользуем между доменами.