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

12 KiB
Raw Blame History

Шаблоны с плейсхолдером {{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:

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:

// 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.goDomainRef += ZoneName string.

internal/store/queries/domains.sql — LoadDomainFull:

-- 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.goLoadDomain возвращает ZoneName: row.ZoneName в DomainRef. (LoadZone можно оставить как есть — snapshot берёт zoneName из GetDomain.)

  • Step 4: resolve материализует

internal/service/service.go resolve — заменить:

cs := diff.Diff(ref.Template.ToModel(), actual)

на:

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-записей заменить:

doc := dto.FromModel(managed)

на:

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.

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.

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}} вместо имени зоны; шаблон переиспользуем между доменами.