feat(api): read zone records without template + snapshot-to-template

LoadDomain requires a template, so a zone without one could never be
viewed or snapshotted. Adds a template-free path: store.LoadZone /
service.ZoneRef / DomainService.ZoneRecords read a zone's live records
straight from the provider (no diff, no template). GET
/domains/{did}/records exposes read-only viewing; POST
/domains/{did}/template-from-zone snapshots only managed record types
(NS/SOA excluded) into a new template and auto-attaches it to the domain.

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 12:00:27 +07:00
parent 1540140542
commit 9ccb304d2e
9 changed files with 294 additions and 1 deletions
+10
View File
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/service" "github.com/vasyakrg/dns-autoresolver/internal/service"
"github.com/vasyakrg/dns-autoresolver/internal/store" "github.com/vasyakrg/dns-autoresolver/internal/store"
@@ -20,6 +21,10 @@ import (
type CheckApplier interface { type CheckApplier interface {
Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error) Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error)
Apply(ctx context.Context, projectID, domainID uuid.UUID, req service.ApplyRequest) (diff.Changeset, error) Apply(ctx context.Context, projectID, domainID uuid.UUID, req service.ApplyRequest) (diff.Changeset, error)
// ZoneRecords reads a zone's current records straight from the provider,
// with no diff and no template required — backs read-only zone viewing
// and the template-from-zone snapshot.
ZoneRecords(ctx context.Context, projectID, domainID uuid.UUID) ([]model.Record, error)
} }
// TenantStore is the narrow persistence surface the CRUD handlers depend on. // TenantStore is the narrow persistence surface the CRUD handlers depend on.
@@ -39,6 +44,9 @@ type TenantStore interface {
CreateDomain(ctx context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (store.Domain, error) CreateDomain(ctx context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (store.Domain, error)
ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error) ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error)
// GetDomain is used by the template-from-zone snapshot to read the
// domain's zone name (for the generated template's name) before creating it.
GetDomain(ctx context.Context, id, projectID uuid.UUID) (store.Domain, error)
DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error
ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error)
SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error) SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error)
@@ -117,6 +125,8 @@ func NewRouter(a *API) http.Handler {
r.Patch("/", a.handleSetDomainTemplate) r.Patch("/", a.handleSetDomainTemplate)
r.Delete("/", a.handleDeleteDomain) r.Delete("/", a.handleDeleteDomain)
r.Get("/history", a.handleDomainHistory) r.Get("/history", a.handleDomainHistory)
r.Get("/records", a.handleZoneRecords)
r.Post("/template-from-zone", a.handleTemplateFromZone)
}) })
}) })
+8
View File
@@ -18,6 +18,8 @@ import (
type mockCheckApplier struct { type mockCheckApplier struct {
lastReq service.ApplyRequest lastReq service.ApplyRequest
zoneRecords []model.Record
zoneErr error
} }
func (m *mockCheckApplier) Check(context.Context, uuid.UUID, uuid.UUID) (diff.Changeset, error) { func (m *mockCheckApplier) Check(context.Context, uuid.UUID, uuid.UUID) (diff.Changeset, error) {
@@ -28,6 +30,12 @@ func (m *mockCheckApplier) Apply(_ context.Context, _, _ uuid.UUID, req service.
m.lastReq = req m.lastReq = req
return diff.Changeset{}, nil return diff.Changeset{}, nil
} }
func (m *mockCheckApplier) ZoneRecords(context.Context, uuid.UUID, uuid.UUID) ([]model.Record, error) {
if m.zoneErr != nil {
return nil, m.zoneErr
}
return m.zoneRecords, nil
}
// newTestAPI wires a fixed authenticated user who owns whatever project id // newTestAPI wires a fixed authenticated user who owns whatever project id
// is requested (via alwaysOwnedAuthStore/alwaysValidSessions in // is requested (via alwaysOwnedAuthStore/alwaysValidSessions in
+67
View File
@@ -10,7 +10,9 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
"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"
) )
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -70,3 +72,68 @@ func (a *API) handleApply(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, toChangesetResponse(cs)) writeJSON(w, http.StatusOK, toChangesetResponse(cs))
} }
// handleZoneRecords reads a zone's current records straight from the
// provider — no template required, no diff computed. Backs read-only zone
// viewing for domains that don't have a template attached (yet).
func (a *API) handleZoneRecords(w http.ResponseWriter, r *http.Request) {
pid, _ := projectIDFrom(r.Context())
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
recs, err := a.Svc.ZoneRecords(r.Context(), pid, did)
if err != nil {
log.Printf("api: zone records failed: %v", err)
writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера")
return
}
doc := dto.FromModel(recs)
writeJSON(w, http.StatusOK, doc.Records) // []dto.RecordDTO
}
// handleTemplateFromZone snapshots a zone's current managed records (NS/SOA
// excluded — they're read-only, never part of a template) into a brand new
// template, then auto-attaches it to the domain so check/apply become
// available immediately.
func (a *API) handleTemplateFromZone(w http.ResponseWriter, r *http.Request) {
pid, _ := projectIDFrom(r.Context())
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
dom, err := a.Store.GetDomain(r.Context(), did, pid)
if err != nil {
log.Printf("api: template-from-zone: get domain failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
recs, err := a.Svc.ZoneRecords(r.Context(), pid, did)
if err != nil {
log.Printf("api: template-from-zone: zone records failed: %v", err)
writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера")
return
}
// Snapshot only managed records — NS/SOA are read-only and never templated.
managed := make([]model.Record, 0, len(recs))
for _, rc := range recs {
if rc.Type.Managed() {
managed = append(managed, rc)
}
}
doc := dto.FromModel(managed)
tmpl, 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 {
log.Printf("api: template-from-zone: attach template failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, toTemplateResponse(tmpl))
}
+4
View File
@@ -13,6 +13,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/diff"
"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" "github.com/vasyakrg/dns-autoresolver/internal/store"
) )
@@ -97,6 +98,9 @@ func (r *recordingCheckApplier) Apply(context.Context, uuid.UUID, uuid.UUID, ser
r.applyCalled = true r.applyCalled = true
return diff.Changeset{}, nil return diff.Changeset{}, nil
} }
func (r *recordingCheckApplier) ZoneRecords(context.Context, uuid.UUID, uuid.UUID) ([]model.Record, error) {
return nil, nil
}
// --- RequireAuth --- // --- RequireAuth ---
+106
View File
@@ -98,6 +98,15 @@ func (m *mockTenantStore) ListDomains(context.Context, uuid.UUID) ([]store.Domai
return m.domains, nil return m.domains, nil
} }
func (m *mockTenantStore) GetDomain(_ context.Context, id, _ uuid.UUID) (store.Domain, error) {
for _, d := range m.domains {
if d.ID == id {
return d, nil
}
}
return store.Domain{}, errors.New("domain not found")
}
func (m *mockTenantStore) DeleteDomain(context.Context, uuid.UUID, uuid.UUID) error { return nil } func (m *mockTenantStore) DeleteDomain(context.Context, uuid.UUID, uuid.UUID) error { return nil }
func (m *mockTenantStore) SetDomainTemplate(_ context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error) { func (m *mockTenantStore) SetDomainTemplate(_ context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error) {
@@ -599,3 +608,100 @@ func TestDeleteDomain_BadUUID(t *testing.T) {
t.Fatalf("expected 400, got %d", w.Code) t.Fatalf("expected 400, got %d", w.Code)
} }
} }
// --- zone view / template-from-zone (Task 1: no-template zone snapshot) ---
// TestZoneRecords_ReturnsProviderRecords covers the read-only zone-viewing
// endpoint: it returns whatever the service reads straight from the
// provider, with no template involved at all.
func TestZoneRecords_ReturnsProviderRecords(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"}},
}}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
var resp []dto.RecordDTO
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp) != 1 || resp[0].Type != "A" {
t.Fatalf("unexpected records response: %+v", resp)
}
}
// 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.
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.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"}},
}}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/template-from-zone", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if ts.createTemplate == nil {
t.Fatal("expected CreateTemplate to be called")
}
if len(ts.createTemplate.Doc.Records) != 2 {
t.Fatalf("expected only the 2 managed records (A+TXT) in the snapshot, got %+v", ts.createTemplate.Doc.Records)
}
for _, r := range ts.createTemplate.Doc.Records {
if r.Type == "NS" || r.Type == "SOA" {
t.Fatalf("read-only record type %s leaked into snapshot template", r.Type)
}
}
// 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 {
t.Fatalf("expected domain auto-attached to new template %s, got %+v", ts.createTemplate.ID, ts.domains[0].TemplateID)
}
var resp templateResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.ID != ts.createTemplate.ID.String() || len(resp.Records) != 2 {
t.Fatalf("unexpected response: %+v", resp)
}
}
// TestZoneRecords_ProviderErrorReturns502 covers the provider-failure path:
// a GetRecords error from the provider must surface as 502 (bad gateway),
// not a generic 500 or a hung response.
func TestZoneRecords_ProviderErrorReturns502(t *testing.T) {
a, ts := newTenantTestAPI()
domID := uuid.New()
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
a.Svc = &mockCheckApplier{zoneErr: errors.New("provider unreachable")}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("expected 502, got %d body %s", w.Code, w.Body.String())
}
}
+29
View File
@@ -7,6 +7,7 @@ import (
"github.com/vasyakrg/dns-autoresolver/internal/crypto" "github.com/vasyakrg/dns-autoresolver/internal/crypto"
"github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"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"
@@ -20,8 +21,17 @@ type DomainRef struct {
Template dto.TemplateDoc Template dto.TemplateDoc
} }
// ZoneRef is the provider-access subset of a domain, without a template —
// enough to read a zone's live records.
type ZoneRef struct {
ZoneID string
Provider string
SecretEnc string
}
type Loader interface { type Loader interface {
LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (DomainRef, error) LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (DomainRef, error)
LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (ZoneRef, error)
} }
type Recorder interface { type Recorder interface {
@@ -81,6 +91,25 @@ func (s *DomainService) Check(ctx context.Context, projectID, domainID uuid.UUID
return cs, nil return cs, nil
} }
// ZoneRecords reads a zone's current records straight from the provider,
// with no diff and no template required. Used for read-only zone viewing and
// as the source for a snapshot template.
func (s *DomainService) ZoneRecords(ctx context.Context, projectID, domainID uuid.UUID) ([]model.Record, error) {
ref, err := s.loader.LoadZone(ctx, projectID, domainID)
if err != nil {
return nil, err
}
p, err := s.reg.ByName(ref.Provider)
if err != nil {
return nil, err
}
secret, err := s.cipher.Decrypt(ref.SecretEnc)
if err != nil {
return nil, err
}
return p.GetRecords(ctx, provider.Credentials{Secret: string(secret)}, ref.ZoneID)
}
// Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes. // Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes.
func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) { func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID) p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID)
+25
View File
@@ -49,6 +49,13 @@ func (l fakeLoader) LoadDomain(context.Context, uuid.UUID, uuid.UUID) (DomainRef
return l.ref, nil return l.ref, nil
} }
// LoadZone mirrors LoadDomain's provider-access fields but — unlike
// LoadDomain — never errors on a missing template, matching the real
// store.LoadZone contract.
func (l fakeLoader) LoadZone(context.Context, uuid.UUID, uuid.UUID) (ZoneRef, error) {
return ZoneRef{ZoneID: l.ref.ZoneID, Provider: l.ref.Provider, SecretEnc: l.ref.SecretEnc}, nil
}
type nopRecorder struct{} type nopRecorder struct{}
func (nopRecorder) SaveCheckRun(context.Context, uuid.UUID, diff.Changeset) error { return nil } func (nopRecorder) SaveCheckRun(context.Context, uuid.UUID, diff.Changeset) error { return nil }
@@ -78,6 +85,24 @@ func TestCheckProducesDiff(t *testing.T) {
} }
} }
// 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).
func TestZoneRecordsReadsProviderDirectly(t *testing.T) {
actual := []model.Record{
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}},
}
svc, _ := setup(t, actual, dto.TemplateDoc{})
recs, err := svc.ZoneRecords(context.Background(), uuid.New(), uuid.New())
if err != nil {
t.Fatal(err)
}
if len(recs) != 2 {
t.Fatalf("expected 2 records straight from the provider, got %+v", recs)
}
}
func TestApplyRespectsPruneGuard(t *testing.T) { func TestApplyRespectsPruneGuard(t *testing.T) {
// зона содержит лишнюю запись b (нет в шаблоне) → Prune-кандидат // зона содержит лишнюю запись b (нет в шаблоне) → Prune-кандидат
actual := []model.Record{ actual := []model.Record{
+12
View File
@@ -33,6 +33,18 @@ func (s *Store) LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (
}, nil }, nil
} }
// LoadZone returns just the provider-access half of a domain (provider name,
// encrypted secret, zone id), WITHOUT requiring an attached template — so a
// zone's live records can be read for viewing/snapshot even when no template
// is set. Scoped by projectID (same IDOR closure as LoadDomain).
func (s *Store) LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (service.ZoneRef, error) {
row, err := s.q.LoadDomainFull(ctx, db.LoadDomainFullParams{ID: domainID, ProjectID: projectID})
if err != nil {
return service.ZoneRef{}, err
}
return service.ZoneRef{ZoneID: row.ZoneID, Provider: row.Provider, SecretEnc: row.SecretEnc}, nil
}
// SaveCheckRun persists a summary of the changeset (counts of updates/prunes) // SaveCheckRun persists a summary of the changeset (counts of updates/prunes)
// as a check_runs row. // as a check_runs row.
func (s *Store) SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error { func (s *Store) SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error {
+32
View File
@@ -91,3 +91,35 @@ func TestLoadDomainNoTemplate(t *testing.T) {
t.Fatal("expected error for domain without template, got nil") t.Fatal("expected error for domain without template, got nil")
} }
} }
// TestLoadZoneNoTemplate covers the Task 1 gap: a domain with no attached
// template must still be resolvable to its provider-access ref (ZoneRef) so
// its live records can be read/snapshotted — unlike LoadDomain, LoadZone
// must NOT error out on a nil template doc.
func TestLoadZoneNoTemplate(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject,
Provider: "selectel", SecretEnc: "enc-blob", Comment: "prod",
})
if err != nil {
t.Fatal(err)
}
domain, err := s.Queries().CreateDomain(ctx, db.CreateDomainParams{
ID: uuid.New(), ProjectID: defaultProject, ProviderAccountID: acc.ID,
ZoneName: "example.com", ZoneID: "zone-3", TemplateID: nil,
})
if err != nil {
t.Fatal(err)
}
ref, err := s.LoadZone(ctx, defaultProject, domain.ID)
if err != nil {
t.Fatalf("expected LoadZone to succeed without a template, got error: %v", err)
}
if ref.ZoneID != "zone-3" || ref.Provider != "selectel" || ref.SecretEnc != "enc-blob" {
t.Fatalf("zone ref mismatch: %+v", ref)
}
}