From 9ccb304d2ed416cedafc5816edc19eb21aa72e60 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 5 Jul 2026 12:00:27 +0700 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- internal/api/api.go | 10 +++ internal/api/api_test.go | 10 ++- internal/api/handlers.go | 67 +++++++++++++++++++ internal/api/middleware_test.go | 4 ++ internal/api/tenant_test.go | 106 +++++++++++++++++++++++++++++++ internal/service/service.go | 29 +++++++++ internal/service/service_test.go | 25 ++++++++ internal/store/loader.go | 12 ++++ internal/store/loader_test.go | 32 ++++++++++ 9 files changed, 294 insertions(+), 1 deletion(-) diff --git a/internal/api/api.go b/internal/api/api.go index d2ee30f..db6c2c9 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) }) }) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 876d06b..1002bd2 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -17,7 +17,9 @@ import ( ) 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) { @@ -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 diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 3553bfa..9088407 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -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)) +} diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index b24ecbd..bcf3385 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -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 --- diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index 2eaea37..a54778c 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -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()) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index ea8c46d..db8efaa 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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) diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 0da37d9..539dcdc 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -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{ diff --git a/internal/store/loader.go b/internal/store/loader.go index bc48f64..33259e5 100644 --- a/internal/store/loader.go +++ b/internal/store/loader.go @@ -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 { diff --git a/internal/store/loader_test.go b/internal/store/loader_test.go index c53a1c7..8541d55 100644 --- a/internal/store/loader_test.go +++ b/internal/store/loader_test.go @@ -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) + } +}