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
+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])
}
}