feat: remove domains whose zones vanished at the provider on import

This commit is contained in:
2026-08-19 18:32:10 +07:00
parent e00648baf7
commit 4d05271f10
8 changed files with 310 additions and 37 deletions
+4 -1
View File
@@ -48,7 +48,10 @@ type TenantStore interface {
// 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)
// ImportDomains synchronises an account's zones with its domains: new
// zones become domains, domains of vanished zones are deleted. Returns
// (created, removed).
ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []store.Domain, error)
SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error)
// SetDomainStatus persists the outcome of a manual check (handleCheck) so
// the domain's badge reflects reality immediately, instead of staying
+8
View File
@@ -79,6 +79,14 @@ func toDomainResponse(d store.Domain) domainResponse {
return resp
}
// importResponse splits an import into what appeared and what vanished at
// the provider. Both slices are initialised so they marshal as [] and never
// as null — clients call .length/.map on them.
type importResponse struct {
Created []domainResponse `json:"created"`
Removed []domainResponse `json:"removed"`
}
// parseOptionalUUID parses s (may be nil/empty) into *uuid.UUID; returns ok=false on invalid input.
func parseOptionalUUID(s *string) (*uuid.UUID, bool) {
if s == nil || *s == "" {
+15 -8
View File
@@ -130,19 +130,26 @@ func (a *API) handleImportZones(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
// Imported atomically: either every zone becomes a domain or none does,
// so a mid-batch provider/DB error never leaves a partial import behind.
doms, err := a.Store.ImportDomains(r.Context(), pid, aid, zones)
// Imported atomically: either every zone becomes a domain (and vanished
// zones' domains are deleted) or none of it happens, so a mid-batch
// provider/DB error never leaves a partial sync behind.
doms, gone, err := a.Store.ImportDomains(r.Context(), pid, aid, zones)
if err != nil {
log.Printf("api: import: create domains failed: %v", err)
log.Printf("api: import: sync domains failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
created := make([]domainResponse, 0, len(doms))
for _, d := range doms {
created = append(created, toDomainResponse(d))
resp := importResponse{
Created: make([]domainResponse, 0, len(doms)),
Removed: make([]domainResponse, 0, len(gone)),
}
writeJSON(w, http.StatusCreated, created)
for _, d := range doms {
resp.Created = append(resp.Created, toDomainResponse(d))
}
for _, d := range gone {
resp.Removed = append(resp.Removed, toDomainResponse(d))
}
writeJSON(w, http.StatusCreated, resp)
}
// --- templates ---
+53 -6
View File
@@ -38,8 +38,13 @@ type mockTenantStore struct {
createDomains int
importDomains []store.Domain
importRemoved []store.Domain
importDomainsErr error
importCalled bool
// importDomainsFn, when set, overrides the default fake behavior of
// ImportDomains — used by tests that need to control created/removed
// independently (e.g. TestImportZonesReturnsCreatedAndRemoved).
importDomainsFn func(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []store.Domain, error)
setDomainTemplateErr error
@@ -186,10 +191,16 @@ func (m *mockTenantStore) DeleteCustomRecord(_ context.Context, domainID, projec
return m.deleteCustomRecordErr
}
func (m *mockTenantStore) ImportDomains(_ context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error) {
func (m *mockTenantStore) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []store.Domain, error) {
m.importCalled = true
if m.importDomainsFn != nil {
created, removed, err := m.importDomainsFn(ctx, projectID, accountID, zones)
m.importDomains, m.importRemoved = created, removed
m.domains = append(m.domains, created...)
return created, removed, err
}
if m.importDomainsErr != nil {
return nil, m.importDomainsErr
return nil, nil, m.importDomainsErr
}
out := make([]store.Domain, 0, len(zones))
for _, z := range zones {
@@ -198,7 +209,7 @@ func (m *mockTenantStore) ImportDomains(_ context.Context, projectID, accountID
}
m.domains = append(m.domains, out...)
m.importDomains = out
return out, nil
return out, nil, nil
}
type mockCipher struct{}
@@ -431,12 +442,15 @@ func TestImportZones_CreatesDomainPerZone(t *testing.T) {
if len(ts.importDomains) != 2 {
t.Fatalf("expected 2 domains created via ImportDomains, got %d", len(ts.importDomains))
}
var resp []domainResponse
var resp importResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp) != 2 {
t.Fatalf("expected 2 domains in response, got %d", len(resp))
if len(resp.Created) != 2 {
t.Fatalf("expected 2 domains in response, got %d", len(resp.Created))
}
if len(resp.Removed) != 0 {
t.Fatalf("expected 0 removed domains in response, got %d", len(resp.Removed))
}
}
@@ -471,6 +485,39 @@ func TestImportZones_AtomicRollbackOnError(t *testing.T) {
}
}
// TestImportZonesReturnsCreatedAndRemoved covers the sync response shape:
// the endpoint now reports both what appeared (created) and what vanished
// at the provider (removed) rather than a bare array of created domains.
func TestImportZonesReturnsCreatedAndRemoved(t *testing.T) {
a, ts := newTenantTestAPI()
accID := uuid.New()
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
ts.importDomainsFn = func(_ context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []store.Domain, error) {
return []store.Domain{{ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: "new.example.com.", ZoneID: "z1"}},
[]store.Domain{{ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: "gone.example.com.", ZoneID: "z9"}},
nil
}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts/"+accID.String()+"/import", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var got importResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("response must be an object with created/removed: %v (%s)", err, w.Body.String())
}
if len(got.Created) != 1 || got.Created[0].ZoneName != "new.example.com." {
t.Fatalf("unexpected created: %+v", got.Created)
}
if len(got.Removed) != 1 || got.Removed[0].ZoneName != "gone.example.com." {
t.Fatalf("unexpected removed: %+v", got.Removed)
}
}
func TestImportZones_BadAccountUUID(t *testing.T) {
a, _ := newTenantTestAPI()
router := NewRouter(a)