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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user