feat(tmpl): {{domain_name}} placeholder — materialize on diff/apply, parameterize on snapshot

Adds internal/tmpl with Materialize (template placeholder -> zone name) and
Parameterize (zone name -> placeholder, the inverse used by the
template-from-zone snapshot). service.resolve now materializes the template
against DomainRef.ZoneName before diffing, so one template can be reused
across domains. LoadDomainFull (source query + hand-edited sqlc output, since
sqlc is not installed) now also selects zone_name to populate it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
2026-07-05 13:41:18 +07:00
parent 135917216c
commit df895d8850
9 changed files with 158 additions and 11 deletions
+5 -4
View File
@@ -13,6 +13,7 @@ import (
"github.com/vasyakrg/dns-autoresolver/internal/model" "github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/service" "github.com/vasyakrg/dns-autoresolver/internal/service"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto" "github.com/vasyakrg/dns-autoresolver/internal/store/dto"
"github.com/vasyakrg/dns-autoresolver/internal/tmpl"
) )
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -141,17 +142,17 @@ func (a *API) handleTemplateFromZone(w http.ResponseWriter, r *http.Request) {
managed = append(managed, rc) managed = append(managed, rc)
} }
} }
doc := dto.FromModel(managed) doc := tmpl.Parameterize(managed, dom.ZoneName)
tmpl, err := a.Store.CreateTemplate(r.Context(), pid, dom.ZoneName+" snapshot", doc) tpl, err := a.Store.CreateTemplate(r.Context(), pid, dom.ZoneName+" snapshot", doc)
if err != nil { if err != nil {
log.Printf("api: template-from-zone: create template failed: %v", err) log.Printf("api: template-from-zone: create template failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error") writeErr(w, http.StatusInternalServerError, "internal error")
return return
} }
if _, err := a.Store.SetDomainTemplate(r.Context(), did, pid, &tmpl.ID); err != nil { if _, err := a.Store.SetDomainTemplate(r.Context(), did, pid, &tpl.ID); err != nil {
log.Printf("api: template-from-zone: attach template failed: %v", err) log.Printf("api: template-from-zone: attach template failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error") writeErr(w, http.StatusInternalServerError, "internal error")
return return
} }
writeJSON(w, http.StatusCreated, toTemplateResponse(tmpl)) writeJSON(w, http.StatusCreated, toTemplateResponse(tpl))
} }
+16 -3
View File
@@ -643,15 +643,17 @@ func TestZoneRecords_ReturnsProviderRecords(t *testing.T) {
// TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches covers the // TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches covers the
// snapshot-to-template flow: NS/SOA are read-only and must be excluded from // snapshot-to-template flow: NS/SOA are read-only and must be excluded from
// the generated template, and the new template must be auto-attached to the // the generated template, the new template must be auto-attached to the
// domain (SetDomainTemplate) so check/apply become immediately available. // domain (SetDomainTemplate) so check/apply become immediately available,
// and the zone name must be parameterized to {{domain_name}} in names/values
// so the resulting template is reusable across domains (tmpl.Parameterize).
func TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches(t *testing.T) { func TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches(t *testing.T) {
a, ts := newTenantTestAPI() a, ts := newTenantTestAPI()
domID := uuid.New() domID := uuid.New()
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}} ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
a.Svc = &mockCheckApplier{zoneRecords: []model.Record{ a.Svc = &mockCheckApplier{zoneRecords: []model.Record{
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, {Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
{Type: model.TXT, Name: "a.example.com.", TTL: 300, Values: []string{"v=spf1 -all"}}, {Type: model.TXT, Name: "a.example.com.", TTL: 300, Values: []string{"v=spf1 a:mail.example.com -all"}},
{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}}, {Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}},
{Type: model.SOA, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com. admin.example.com. 1 2 3 4 5"}}, {Type: model.SOA, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com. admin.example.com. 1 2 3 4 5"}},
}} }}
@@ -674,6 +676,17 @@ func TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches(t *testing.T) {
if r.Type == "NS" || r.Type == "SOA" { if r.Type == "NS" || r.Type == "SOA" {
t.Fatalf("read-only record type %s leaked into snapshot template", r.Type) t.Fatalf("read-only record type %s leaked into snapshot template", r.Type)
} }
if strings.Contains(r.Name, "example.com") {
t.Fatalf("expected zone name parameterized to {{domain_name}} in record name, got %+v", r)
}
for _, v := range r.Values {
if strings.Contains(v, "example.com") {
t.Fatalf("expected zone name parameterized to {{domain_name}} in record value, got %+v", r)
}
}
}
if ts.createTemplate.Doc.Records[1].Values[0] != "v=spf1 a:mail.{{domain_name}} -all" {
t.Fatalf("expected SPF value parameterized, got %q", ts.createTemplate.Doc.Records[1].Values[0])
} }
// SetDomainTemplate must have been called with the newly created template's id. // SetDomainTemplate must have been called with the newly created template's id.
if ts.domains[0].TemplateID == nil || *ts.domains[0].TemplateID != ts.createTemplate.ID { if ts.domains[0].TemplateID == nil || *ts.domains[0].TemplateID != ts.createTemplate.ID {
+3 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/provider/registry" "github.com/vasyakrg/dns-autoresolver/internal/provider/registry"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto" "github.com/vasyakrg/dns-autoresolver/internal/store/dto"
"github.com/vasyakrg/dns-autoresolver/internal/tmpl"
) )
// ErrProviderUnavailable marks failures that happened while talking to the // ErrProviderUnavailable marks failures that happened while talking to the
@@ -25,6 +26,7 @@ var ErrProviderUnavailable = errors.New("service: provider unavailable")
// DomainRef is the minimal data the service needs about a domain. // DomainRef is the minimal data the service needs about a domain.
type DomainRef struct { type DomainRef struct {
ZoneID string ZoneID string
ZoneName string
Provider string Provider string
SecretEnc string SecretEnc string
Template dto.TemplateDoc Template dto.TemplateDoc
@@ -84,7 +86,7 @@ func (s *DomainService) resolve(ctx context.Context, projectID, domainID uuid.UU
if err != nil { if err != nil {
return nil, provider.Credentials{}, ref, diff.Changeset{}, err return nil, provider.Credentials{}, ref, diff.Changeset{}, err
} }
cs := diff.Diff(ref.Template.ToModel(), actual) cs := diff.Diff(tmpl.Materialize(ref.Template, ref.ZoneName), actual)
return p, creds, ref, cs, nil return p, creds, ref, cs, nil
} }
+23 -1
View File
@@ -66,7 +66,7 @@ func setup(t *testing.T, actual []model.Record, tmpl dto.TemplateDoc) (*DomainSe
reg.Register(fp) reg.Register(fp)
cipher := testCipher(t) cipher := testCipher(t)
enc, _ := cipher.Encrypt([]byte("secret")) enc, _ := cipher.Encrypt([]byte("secret"))
loader := fakeLoader{ref: DomainRef{ZoneID: "z1", Provider: "selectel", SecretEnc: enc, Template: tmpl}} loader := fakeLoader{ref: DomainRef{ZoneID: "z1", ZoneName: "example.com.", Provider: "selectel", SecretEnc: enc, Template: tmpl}}
return New(loader, nopRecorder{}, reg, cipher), fp return New(loader, nopRecorder{}, reg, cipher), fp
} }
@@ -85,6 +85,28 @@ func TestCheckProducesDiff(t *testing.T) {
} }
} }
// TestCheckMaterializesDomainNamePlaceholder covers the tmpl.Materialize
// wiring in resolve: a template record using {{domain_name}} in its name and
// values must be diffed against the zone using the actual zone name (without
// trailing dot) substituted in, so a template reused across domains reports
// in-sync rather than spurious add/delete pairs.
func TestCheckMaterializesDomainNamePlaceholder(t *testing.T) {
actual := []model.Record{
{Type: model.TXT, Name: "_dmarc.example.com.", TTL: 600, Values: []string{"v=DMARC1; p=quarantine"}},
}
tmpl := dto.TemplateDoc{Records: []dto.RecordDTO{
{Type: "TXT", Name: "_dmarc.{{domain_name}}.", TTL: 600, Values: []string{"v=DMARC1; p=quarantine"}},
}}
svc, _ := setup(t, actual, tmpl)
cs, err := svc.Check(context.Background(), uuid.New(), uuid.New())
if err != nil {
t.Fatal(err)
}
if len(cs.Updates()) != 0 || len(cs.Prunes()) != 0 {
t.Fatalf("expected placeholder materialized against zone name to be in sync, got updates=%+v prunes=%+v", cs.Updates(), cs.Prunes())
}
}
// TestZoneRecordsReadsProviderDirectly covers the no-template zone-viewing // TestZoneRecordsReadsProviderDirectly covers the no-template zone-viewing
// path: ZoneRecords must return the provider's live records with no diff // path: ZoneRecords must return the provider's live records with no diff
// and no template involved (loader's Template field is left zero-valued). // and no template involved (loader's Template field is left zero-valued).
+3 -1
View File
@@ -184,7 +184,7 @@ func (q *Queries) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domai
} }
const loadDomainFull = `-- name: LoadDomainFull :one const loadDomainFull = `-- name: LoadDomainFull :one
SELECT d.zone_id, a.provider, a.secret_enc, t.doc SELECT d.zone_id, d.zone_name, a.provider, a.secret_enc, t.doc
FROM domains d FROM domains d
JOIN provider_accounts a ON a.id = d.provider_account_id JOIN provider_accounts a ON a.id = d.provider_account_id
LEFT JOIN templates t ON t.id = d.template_id LEFT JOIN templates t ON t.id = d.template_id
@@ -198,6 +198,7 @@ type LoadDomainFullParams struct {
type LoadDomainFullRow struct { type LoadDomainFullRow struct {
ZoneID string `json:"zone_id"` ZoneID string `json:"zone_id"`
ZoneName string `json:"zone_name"`
Provider string `json:"provider"` Provider string `json:"provider"`
SecretEnc string `json:"secret_enc"` SecretEnc string `json:"secret_enc"`
Doc *dto.TemplateDoc `json:"doc"` Doc *dto.TemplateDoc `json:"doc"`
@@ -208,6 +209,7 @@ func (q *Queries) LoadDomainFull(ctx context.Context, arg LoadDomainFullParams)
var i LoadDomainFullRow var i LoadDomainFullRow
err := row.Scan( err := row.Scan(
&i.ZoneID, &i.ZoneID,
&i.ZoneName,
&i.Provider, &i.Provider,
&i.SecretEnc, &i.SecretEnc,
&i.Doc, &i.Doc,
+1
View File
@@ -27,6 +27,7 @@ func (s *Store) LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (
} }
return service.DomainRef{ return service.DomainRef{
ZoneID: row.ZoneID, ZoneID: row.ZoneID,
ZoneName: row.ZoneName,
Provider: row.Provider, Provider: row.Provider,
SecretEnc: row.SecretEnc, SecretEnc: row.SecretEnc,
Template: *row.Doc, Template: *row.Doc,
+1 -1
View File
@@ -23,7 +23,7 @@ SELECT * FROM domains WHERE project_id = $1 ORDER BY created_at;
DELETE FROM domains WHERE id = $1 AND project_id = $2; DELETE FROM domains WHERE id = $1 AND project_id = $2;
-- name: LoadDomainFull :one -- name: LoadDomainFull :one
SELECT d.zone_id, a.provider, a.secret_enc, t.doc SELECT d.zone_id, d.zone_name, a.provider, a.secret_enc, t.doc
FROM domains d FROM domains d
JOIN provider_accounts a ON a.id = d.provider_account_id JOIN provider_accounts a ON a.id = d.provider_account_id
LEFT JOIN templates t ON t.id = d.template_id LEFT JOIN templates t ON t.id = d.template_id
+61
View File
@@ -0,0 +1,61 @@
// 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 (without the trailing dot) 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
}
+45
View File
@@ -0,0 +1,45 @@
package tmpl_test
import (
"testing"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
"github.com/vasyakrg/dns-autoresolver/internal/tmpl"
)
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])
}
}