Files
dns-autoresolver/docs/superpowers/plans/2026-07-05-template-placeholders.md
2026-07-05 13:34:00 +07:00

222 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Шаблоны с плейсхолдером {{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}}` вместо имени зоны; шаблон переиспользуем между доменами.