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/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/service"
"github.com/vasyakrg/dns-autoresolver/internal/store"
@@ -20,6 +21,10 @@ import (
type CheckApplier interface {
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)
// 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.
@@ -39,6 +44,9 @@ type TenantStore interface {
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)
// 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
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)
@@ -117,6 +125,8 @@ func NewRouter(a *API) http.Handler {
r.Patch("/", a.handleSetDomainTemplate)
r.Delete("/", a.handleDeleteDomain)
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 {
lastReq service.ApplyRequest
zoneRecords []model.Record
zoneErr 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
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
// is requested (via alwaysOwnedAuthStore/alwaysValidSessions in
+67
View File
@@ -10,7 +10,9 @@ import (
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/service"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
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))
}
// 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/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/store"
)
@@ -97,6 +98,9 @@ func (r *recordingCheckApplier) Apply(context.Context, uuid.UUID, uuid.UUID, ser
r.applyCalled = true
return diff.Changeset{}, nil
}
func (r *recordingCheckApplier) ZoneRecords(context.Context, uuid.UUID, uuid.UUID) ([]model.Record, error) {
return nil, nil
}
// --- RequireAuth ---
+106
View File
@@ -98,6 +98,15 @@ func (m *mockTenantStore) ListDomains(context.Context, uuid.UUID) ([]store.Domai
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) 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)
}
}
// --- 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/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/provider/registry"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
@@ -20,8 +21,17 @@ type DomainRef struct {
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 {
LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (DomainRef, error)
LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (ZoneRef, error)
}
type Recorder interface {
@@ -81,6 +91,25 @@ func (s *DomainService) Check(ctx context.Context, projectID, domainID uuid.UUID
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.
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)
+25
View File
@@ -49,6 +49,13 @@ func (l fakeLoader) LoadDomain(context.Context, uuid.UUID, uuid.UUID) (DomainRef
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{}
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) {
// зона содержит лишнюю запись b (нет в шаблоне) → Prune-кандидат
actual := []model.Record{
+12
View File
@@ -33,6 +33,18 @@ func (s *Store) LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (
}, 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)
// as a check_runs row.
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")
}
}
// 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)
}
}