From df895d8850bd21ea91a3d6b6b9c4422ecc6fe89f Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 5 Jul 2026 13:41:18 +0700 Subject: [PATCH] =?UTF-8?q?feat(tmpl):=20{{domain=5Fname}}=20placeholder?= =?UTF-8?q?=20=E2=80=94=20materialize=20on=20diff/apply,=20parameterize=20?= =?UTF-8?q?on=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- internal/api/handlers.go | 9 +++-- internal/api/tenant_test.go | 19 ++++++++-- internal/service/service.go | 4 +- internal/service/service_test.go | 24 +++++++++++- internal/store/db/domains.sql.go | 4 +- internal/store/loader.go | 1 + internal/store/queries/domains.sql | 2 +- internal/tmpl/tmpl.go | 61 ++++++++++++++++++++++++++++++ internal/tmpl/tmpl_test.go | 45 ++++++++++++++++++++++ 9 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 internal/tmpl/tmpl.go create mode 100644 internal/tmpl/tmpl_test.go diff --git a/internal/api/handlers.go b/internal/api/handlers.go index e46ce21..fd85cc4 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -13,6 +13,7 @@ import ( "github.com/vasyakrg/dns-autoresolver/internal/model" "github.com/vasyakrg/dns-autoresolver/internal/service" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" + "github.com/vasyakrg/dns-autoresolver/internal/tmpl" ) 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) } } - doc := dto.FromModel(managed) - tmpl, err := a.Store.CreateTemplate(r.Context(), pid, dom.ZoneName+" snapshot", doc) + doc := tmpl.Parameterize(managed, dom.ZoneName) + tpl, err := a.Store.CreateTemplate(r.Context(), pid, dom.ZoneName+" snapshot", doc) if err != nil { log.Printf("api: template-from-zone: create template failed: %v", err) writeErr(w, http.StatusInternalServerError, "internal error") 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) writeErr(w, http.StatusInternalServerError, "internal error") return } - writeJSON(w, http.StatusCreated, toTemplateResponse(tmpl)) + writeJSON(w, http.StatusCreated, toTemplateResponse(tpl)) } diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index bf1e140..9c845a0 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -643,15 +643,17 @@ func TestZoneRecords_ReturnsProviderRecords(t *testing.T) { // TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches covers the // 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 -// domain (SetDomainTemplate) so check/apply become immediately available. +// the generated template, the new template must be auto-attached to the +// 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) { a, ts := newTenantTestAPI() domID := uuid.New() ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}} a.Svc = &mockCheckApplier{zoneRecords: []model.Record{ {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.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" { 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. if ts.domains[0].TemplateID == nil || *ts.domains[0].TemplateID != ts.createTemplate.ID { diff --git a/internal/service/service.go b/internal/service/service.go index c16e0fd..a4662fa 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -13,6 +13,7 @@ import ( "github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/provider/registry" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" + "github.com/vasyakrg/dns-autoresolver/internal/tmpl" ) // 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. type DomainRef struct { ZoneID string + ZoneName string Provider string SecretEnc string Template dto.TemplateDoc @@ -84,7 +86,7 @@ func (s *DomainService) resolve(ctx context.Context, projectID, domainID uuid.UU if err != nil { 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 } diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 539dcdc..4bcea46 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -66,7 +66,7 @@ func setup(t *testing.T, actual []model.Record, tmpl dto.TemplateDoc) (*DomainSe reg.Register(fp) cipher := testCipher(t) 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 } @@ -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 // path: ZoneRecords must return the provider's live records with no diff // and no template involved (loader's Template field is left zero-valued). diff --git a/internal/store/db/domains.sql.go b/internal/store/db/domains.sql.go index 805bd7d..4ac5c7a 100644 --- a/internal/store/db/domains.sql.go +++ b/internal/store/db/domains.sql.go @@ -184,7 +184,7 @@ func (q *Queries) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domai } 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 JOIN provider_accounts a ON a.id = d.provider_account_id LEFT JOIN templates t ON t.id = d.template_id @@ -198,6 +198,7 @@ type LoadDomainFullParams struct { type LoadDomainFullRow struct { ZoneID string `json:"zone_id"` + ZoneName string `json:"zone_name"` Provider string `json:"provider"` SecretEnc string `json:"secret_enc"` Doc *dto.TemplateDoc `json:"doc"` @@ -208,6 +209,7 @@ func (q *Queries) LoadDomainFull(ctx context.Context, arg LoadDomainFullParams) var i LoadDomainFullRow err := row.Scan( &i.ZoneID, + &i.ZoneName, &i.Provider, &i.SecretEnc, &i.Doc, diff --git a/internal/store/loader.go b/internal/store/loader.go index 33259e5..4b6068e 100644 --- a/internal/store/loader.go +++ b/internal/store/loader.go @@ -27,6 +27,7 @@ func (s *Store) LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) ( } return service.DomainRef{ ZoneID: row.ZoneID, + ZoneName: row.ZoneName, Provider: row.Provider, SecretEnc: row.SecretEnc, Template: *row.Doc, diff --git a/internal/store/queries/domains.sql b/internal/store/queries/domains.sql index 6cd3fcf..2387e19 100644 --- a/internal/store/queries/domains.sql +++ b/internal/store/queries/domains.sql @@ -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; -- 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 JOIN provider_accounts a ON a.id = d.provider_account_id LEFT JOIN templates t ON t.id = d.template_id diff --git a/internal/tmpl/tmpl.go b/internal/tmpl/tmpl.go new file mode 100644 index 0000000..c0a9fd9 --- /dev/null +++ b/internal/tmpl/tmpl.go @@ -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 +} diff --git a/internal/tmpl/tmpl_test.go b/internal/tmpl/tmpl_test.go new file mode 100644 index 0000000..b777ec0 --- /dev/null +++ b/internal/tmpl/tmpl_test.go @@ -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]) + } +}