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.
|
// domain's zone name (for the generated template's name) before creating it.
|
||||||
GetDomain(ctx context.Context, id, projectID uuid.UUID) (store.Domain, error)
|
GetDomain(ctx context.Context, id, projectID uuid.UUID) (store.Domain, error)
|
||||||
DeleteDomain(ctx context.Context, id, projectID uuid.UUID) 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)
|
SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error)
|
||||||
// SetDomainStatus persists the outcome of a manual check (handleCheck) so
|
// SetDomainStatus persists the outcome of a manual check (handleCheck) so
|
||||||
// the domain's badge reflects reality immediately, instead of staying
|
// the domain's badge reflects reality immediately, instead of staying
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ func toDomainResponse(d store.Domain) domainResponse {
|
|||||||
return resp
|
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.
|
// parseOptionalUUID parses s (may be nil/empty) into *uuid.UUID; returns ok=false on invalid input.
|
||||||
func parseOptionalUUID(s *string) (*uuid.UUID, bool) {
|
func parseOptionalUUID(s *string) (*uuid.UUID, bool) {
|
||||||
if s == nil || *s == "" {
|
if s == nil || *s == "" {
|
||||||
|
|||||||
@@ -130,19 +130,26 @@ func (a *API) handleImportZones(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Imported atomically: either every zone becomes a domain or none does,
|
// Imported atomically: either every zone becomes a domain (and vanished
|
||||||
// so a mid-batch provider/DB error never leaves a partial import behind.
|
// zones' domains are deleted) or none of it happens, so a mid-batch
|
||||||
doms, err := a.Store.ImportDomains(r.Context(), pid, aid, zones)
|
// provider/DB error never leaves a partial sync behind.
|
||||||
|
doms, gone, err := a.Store.ImportDomains(r.Context(), pid, aid, zones)
|
||||||
if err != nil {
|
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")
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
created := make([]domainResponse, 0, len(doms))
|
resp := importResponse{
|
||||||
for _, d := range doms {
|
Created: make([]domainResponse, 0, len(doms)),
|
||||||
created = append(created, toDomainResponse(d))
|
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 ---
|
// --- templates ---
|
||||||
|
|||||||
@@ -38,8 +38,13 @@ type mockTenantStore struct {
|
|||||||
createDomains int
|
createDomains int
|
||||||
|
|
||||||
importDomains []store.Domain
|
importDomains []store.Domain
|
||||||
|
importRemoved []store.Domain
|
||||||
importDomainsErr error
|
importDomainsErr error
|
||||||
importCalled bool
|
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
|
setDomainTemplateErr error
|
||||||
|
|
||||||
@@ -186,10 +191,16 @@ func (m *mockTenantStore) DeleteCustomRecord(_ context.Context, domainID, projec
|
|||||||
return m.deleteCustomRecordErr
|
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
|
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 {
|
if m.importDomainsErr != nil {
|
||||||
return nil, m.importDomainsErr
|
return nil, nil, m.importDomainsErr
|
||||||
}
|
}
|
||||||
out := make([]store.Domain, 0, len(zones))
|
out := make([]store.Domain, 0, len(zones))
|
||||||
for _, z := range zones {
|
for _, z := range zones {
|
||||||
@@ -198,7 +209,7 @@ func (m *mockTenantStore) ImportDomains(_ context.Context, projectID, accountID
|
|||||||
}
|
}
|
||||||
m.domains = append(m.domains, out...)
|
m.domains = append(m.domains, out...)
|
||||||
m.importDomains = out
|
m.importDomains = out
|
||||||
return out, nil
|
return out, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockCipher struct{}
|
type mockCipher struct{}
|
||||||
@@ -431,12 +442,15 @@ func TestImportZones_CreatesDomainPerZone(t *testing.T) {
|
|||||||
if len(ts.importDomains) != 2 {
|
if len(ts.importDomains) != 2 {
|
||||||
t.Fatalf("expected 2 domains created via ImportDomains, got %d", len(ts.importDomains))
|
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 {
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(resp) != 2 {
|
if len(resp.Created) != 2 {
|
||||||
t.Fatalf("expected 2 domains in response, got %d", len(resp))
|
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) {
|
func TestImportZones_BadAccountUUID(t *testing.T) {
|
||||||
a, _ := newTenantTestAPI()
|
a, _ := newTenantTestAPI()
|
||||||
router := NewRouter(a)
|
router := NewRouter(a)
|
||||||
|
|||||||
@@ -75,6 +75,47 @@ func (q *Queries) DeleteDomain(ctx context.Context, arg DeleteDomainParams) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteDomainsNotInZones = `-- name: DeleteDomainsNotInZones :many
|
||||||
|
DELETE FROM domains
|
||||||
|
WHERE project_id = $1 AND provider_account_id = $2 AND zone_id <> ALL($3::text[])
|
||||||
|
RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteDomainsNotInZonesParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
ProviderAccountID uuid.UUID `json:"provider_account_id"`
|
||||||
|
ZoneIds []string `json:"zone_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteDomainsNotInZones(ctx context.Context, arg DeleteDomainsNotInZonesParams) ([]Domain, error) {
|
||||||
|
rows, err := q.db.Query(ctx, deleteDomainsNotInZones, arg.ProjectID, arg.ProviderAccountID, arg.ZoneIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Domain
|
||||||
|
for rows.Next() {
|
||||||
|
var i Domain
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.ProviderAccountID,
|
||||||
|
&i.ZoneName,
|
||||||
|
&i.ZoneID,
|
||||||
|
&i.TemplateID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.LastCheckStatus,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const getDomain = `-- name: GetDomain :one
|
const getDomain = `-- name: GetDomain :one
|
||||||
SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE id = $1 AND project_id = $2
|
SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE id = $1 AND project_id = $2
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -37,3 +37,8 @@ UPDATE domains SET last_check_status = $2 WHERE id = $1 AND project_id = $3;
|
|||||||
|
|
||||||
-- name: CountDriftDomains :one
|
-- name: CountDriftDomains :one
|
||||||
SELECT count(*) FROM domains WHERE last_check_status = 'drift';
|
SELECT count(*) FROM domains WHERE last_check_status = 'drift';
|
||||||
|
|
||||||
|
-- name: DeleteDomainsNotInZones :many
|
||||||
|
DELETE FROM domains
|
||||||
|
WHERE project_id = $1 AND provider_account_id = $2 AND zone_id <> ALL($3::text[])
|
||||||
|
RETURNING *;
|
||||||
|
|||||||
@@ -103,13 +103,16 @@ func TestImportDomains_CommitsAllOnSuccess(t *testing.T) {
|
|||||||
{ID: "z1", Name: "a.example.com"},
|
{ID: "z1", Name: "a.example.com"},
|
||||||
{ID: "z2", Name: "b.example.com"},
|
{ID: "z2", Name: "b.example.com"},
|
||||||
}
|
}
|
||||||
doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
|
doms, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(doms) != 2 {
|
if len(doms) != 2 {
|
||||||
t.Fatalf("expected 2 domains returned, got %d", len(doms))
|
t.Fatalf("expected 2 domains returned, got %d", len(doms))
|
||||||
}
|
}
|
||||||
|
if len(removed) != 0 {
|
||||||
|
t.Fatalf("expected 0 removed domains, got %d", len(removed))
|
||||||
|
}
|
||||||
|
|
||||||
list, err := s.ListDomains(ctx, defaultProject)
|
list, err := s.ListDomains(ctx, defaultProject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -131,7 +134,7 @@ func TestImportDomains_RollsBackAllOnError(t *testing.T) {
|
|||||||
{ID: "z1", Name: "a.example.com"},
|
{ID: "z1", Name: "a.example.com"},
|
||||||
{ID: "z2", Name: "b.example.com"},
|
{ID: "z2", Name: "b.example.com"},
|
||||||
}
|
}
|
||||||
if _, err := s.ImportDomains(ctx, defaultProject, bogusAccountID, zones); err == nil {
|
if _, _, err := s.ImportDomains(ctx, defaultProject, bogusAccountID, zones); err == nil {
|
||||||
t.Fatal("expected FK violation error, got nil")
|
t.Fatal("expected FK violation error, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +164,7 @@ func TestImportDomains_IdempotentOnRepeat(t *testing.T) {
|
|||||||
{ID: "z1", Name: "a.example.com"},
|
{ID: "z1", Name: "a.example.com"},
|
||||||
{ID: "z2", Name: "b.example.com"},
|
{ID: "z2", Name: "b.example.com"},
|
||||||
}
|
}
|
||||||
first, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
|
first, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -169,13 +172,16 @@ func TestImportDomains_IdempotentOnRepeat(t *testing.T) {
|
|||||||
t.Fatalf("expected 2 domains on first import, got %d", len(first))
|
t.Fatalf("expected 2 domains on first import, got %d", len(first))
|
||||||
}
|
}
|
||||||
|
|
||||||
second, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
|
second, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected repeat import to succeed idempotently, got error: %v", err)
|
t.Fatalf("expected repeat import to succeed idempotently, got error: %v", err)
|
||||||
}
|
}
|
||||||
if len(second) != 0 {
|
if len(second) != 0 {
|
||||||
t.Fatalf("expected 0 newly-created domains on repeat import, got %d", len(second))
|
t.Fatalf("expected 0 newly-created domains on repeat import, got %d", len(second))
|
||||||
}
|
}
|
||||||
|
if len(removed) != 0 {
|
||||||
|
t.Fatalf("expected 0 removed domains on repeat import with the same zones, got %d", len(removed))
|
||||||
|
}
|
||||||
|
|
||||||
list, err := s.ListDomains(ctx, defaultProject)
|
list, err := s.ListDomains(ctx, defaultProject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,7 +213,7 @@ func TestSetDomainTemplate_ClosesImportCheckLoop(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
|
doms, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -255,7 +261,7 @@ func TestSetDomainTemplate_RejectsForeignProjectTemplate(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
|
doms, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -289,7 +295,7 @@ func seedDomain(t *testing.T, s *Store, ctx context.Context) Domain {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
|
doms, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -363,3 +369,140 @@ func TestCustomRecordsDeleteAndCascade(t *testing.T) {
|
|||||||
t.Fatalf("expected cascade to remove keys with the domain, got %+v", keys)
|
t.Fatalf("expected cascade to remove keys with the domain, got %+v", keys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedAccount creates an additional provider account for an existing
|
||||||
|
// project — used to test that ImportDomains scopes its deletions to a
|
||||||
|
// single account, not the whole project.
|
||||||
|
func seedAccount(t *testing.T, s *Store, ctx context.Context, projectID uuid.UUID) Account {
|
||||||
|
t.Helper()
|
||||||
|
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
|
||||||
|
ID: uuid.New(), ProjectID: projectID, Provider: "selectel", SecretEnc: "enc-blob",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return accountFromDB(acc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportDomainsRemovesVanishedZones verifies the sync behavior added to
|
||||||
|
// ImportDomains: a domain whose zone no longer appears in the provider's
|
||||||
|
// zone list is deleted on the next import, closing the orphan-domain gap
|
||||||
|
// (previously such a domain sat forever in status "error").
|
||||||
|
func TestImportDomainsRemovesVanishedZones(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",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
|
||||||
|
{ID: "z1", Name: "one.example.com."},
|
||||||
|
{ID: "z2", Name: "two.example.com."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(created) != 2 || len(removed) != 0 {
|
||||||
|
t.Fatalf("first import: expected 2 created / 0 removed, got %d/%d", len(created), len(removed))
|
||||||
|
}
|
||||||
|
|
||||||
|
// z2 disappeared at the provider — re-import must remove its domain.
|
||||||
|
created, removed, err = s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
|
||||||
|
{ID: "z1", Name: "one.example.com."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(created) != 0 {
|
||||||
|
t.Fatalf("expected nothing new to be created, got %+v", created)
|
||||||
|
}
|
||||||
|
if len(removed) != 1 || removed[0].ZoneID != "z2" {
|
||||||
|
t.Fatalf("expected the vanished zone's domain to be removed, got %+v", removed)
|
||||||
|
}
|
||||||
|
left, err := s.ListDomains(ctx, defaultProject)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(left) != 1 || left[0].ZoneID != "z1" {
|
||||||
|
t.Fatalf("expected only z1 to survive, got %+v", left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportDomainsEmptyZoneListRemovesNothing verifies the safety guard: an
|
||||||
|
// empty zone list from the provider is indistinguishable from a temporary
|
||||||
|
// loss of access, so it must never delete anything.
|
||||||
|
func TestImportDomainsEmptyZoneListRemovesNothing(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",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
|
||||||
|
{ID: "z1", Name: "one.example.com."},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(created) != 0 || len(removed) != 0 {
|
||||||
|
t.Fatalf("empty zone list must be a no-op, got %d created / %d removed", len(created), len(removed))
|
||||||
|
}
|
||||||
|
left, err := s.ListDomains(ctx, defaultProject)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(left) != 1 {
|
||||||
|
t.Fatalf("expected the domain to survive an empty zone list, got %+v", left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportDomainsOnlyTouchesItsOwnAccount verifies deletion is scoped to
|
||||||
|
// the account being imported: a project may hold several provider accounts,
|
||||||
|
// and one account's zone list says nothing about another account's domains.
|
||||||
|
func TestImportDomainsOnlyTouchesItsOwnAccount(t *testing.T) {
|
||||||
|
s, ctx := newStore(t)
|
||||||
|
accA, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
|
||||||
|
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
accB := seedAccount(t, s, ctx, defaultProject)
|
||||||
|
|
||||||
|
if _, _, err := s.ImportDomains(ctx, defaultProject, accB.ID, []provider.Zone{
|
||||||
|
{ID: "zb", Name: "b.example.com."},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.ImportDomains(ctx, defaultProject, accA.ID, []provider.Zone{
|
||||||
|
{ID: "za", Name: "a.example.com."},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-importing accA with the same single zone must not touch accB's domains.
|
||||||
|
_, removed, err := s.ImportDomains(ctx, defaultProject, accA.ID, []provider.Zone{
|
||||||
|
{ID: "za", Name: "a.example.com."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(removed) != 0 {
|
||||||
|
t.Fatalf("import for one account must not remove another account's domains, got %+v", removed)
|
||||||
|
}
|
||||||
|
left, err := s.ListDomains(ctx, defaultProject)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(left) != 2 {
|
||||||
|
t.Fatalf("expected both accounts' domains to survive, got %+v", left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+34
-15
@@ -187,25 +187,30 @@ func (s *Store) GetDomain(ctx context.Context, id, projectID uuid.UUID) (Domain,
|
|||||||
return domainFromDB(d), nil
|
return domainFromDB(d), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportDomains creates one domain per zone inside a single transaction: if
|
// ImportDomains synchronises a provider account's zones with the domains
|
||||||
// any zone fails to be created, the whole batch is rolled back so callers
|
// stored for it, inside a single transaction: zones without a domain are
|
||||||
// never observe a partially-imported set of domains.
|
// created (ON CONFLICT DO NOTHING keeps repeated imports idempotent), and
|
||||||
|
// domains whose zone no longer exists at the provider are deleted. Deletion
|
||||||
|
// is scoped to accountID — a project may hold several provider accounts, and
|
||||||
|
// one account's zone list says nothing about another's domains.
|
||||||
//
|
//
|
||||||
// Import is idempotent: zones that already have a domain for this project
|
// An empty zone list removes nothing: it is indistinguishable from an account
|
||||||
// (enforced by the domains_project_zone_uniq constraint) are silently
|
// that temporarily lost access to its zones, and the cost of being wrong is
|
||||||
// skipped via ON CONFLICT DO NOTHING rather than erroring or duplicating —
|
// every domain of the account gone along with its check history.
|
||||||
// so a repeated POST .../import never creates duplicate domains. Only the
|
//
|
||||||
// zones that were actually newly created are returned.
|
// Returns the domains actually created and the ones actually removed.
|
||||||
func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]Domain, error) {
|
func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]Domain, []Domain, error) {
|
||||||
tx, err := s.pool.Begin(ctx)
|
tx, err := s.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx) // no-op once Commit has succeeded
|
defer tx.Rollback(ctx) // no-op once Commit has succeeded
|
||||||
|
|
||||||
q := s.q.WithTx(tx)
|
q := s.q.WithTx(tx)
|
||||||
out := make([]Domain, 0, len(zones))
|
created := make([]Domain, 0, len(zones))
|
||||||
|
zoneIDs := make([]string, 0, len(zones))
|
||||||
for _, z := range zones {
|
for _, z := range zones {
|
||||||
|
zoneIDs = append(zoneIDs, z.ID)
|
||||||
d, err := q.ImportDomain(ctx, db.ImportDomainParams{
|
d, err := q.ImportDomain(ctx, db.ImportDomainParams{
|
||||||
ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID,
|
ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID,
|
||||||
ZoneName: z.Name, ZoneID: z.ID, TemplateID: nil,
|
ZoneName: z.Name, ZoneID: z.ID, TemplateID: nil,
|
||||||
@@ -216,14 +221,28 @@ func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUI
|
|||||||
// for this project — skip it rather than fail the batch.
|
// for this project — skip it rather than fail the batch.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
out = append(out, domainFromDB(d))
|
created = append(created, domainFromDB(d))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removed := make([]Domain, 0)
|
||||||
|
if len(zoneIDs) > 0 {
|
||||||
|
gone, err := q.DeleteDomainsNotInZones(ctx, db.DeleteDomainsNotInZonesParams{
|
||||||
|
ProjectID: projectID, ProviderAccountID: accountID, ZoneIds: zoneIDs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for _, d := range gone {
|
||||||
|
removed = append(removed, domainFromDB(d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return out, nil
|
return created, removed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDomainTemplate attaches (or clears, when templateID is nil) the DNS
|
// SetDomainTemplate attaches (or clears, when templateID is nil) the DNS
|
||||||
|
|||||||
Reference in New Issue
Block a user