feat: remove domains whose zones vanished at the provider on import
This commit is contained in:
+4
-1
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user