From 647afcfbe77117ee6446b57d0ca40e034f028d48 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 1 May 2026 21:48:25 +0200 Subject: [PATCH] Suspendedremoval display in Frontend --- src/lib/db.ts | 77 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/src/lib/db.ts b/src/lib/db.ts index 6bee5d5..d7d369c 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -63,9 +63,14 @@ const MIGRATION_SQL = ` CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status); CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id); CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_status ON tenant_requests(zitadel_org_id, status); - CREATE UNIQUE INDEX IF NOT EXISTS uniq_tenant_requests_tenant_name - ON tenant_requests(tenant_name) - WHERE tenant_name IS NOT NULL; + -- Note: the unique constraint on tenant_name is NOT created here. + -- Pre-Bug-37 we had a non-partial UNIQUE on tenant_name, which is + -- incompatible with resume requests (same tenant_name, different + -- request_type). The new partial unique indexes are created + -- further down in the migration block, after the request_type + -- column has been added and backfilled. This bootstrap section + -- only creates indexes that are safe regardless of request_type + -- semantics. -- Idempotent column adds for existing databases ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS encrypted_secrets BYTEA; @@ -640,9 +645,7 @@ export async function deleteTenantRequest(id: string): Promise { /** * Reconcile the portal's tenant_requests table against actual cluster - * state. Two passes, both walking only rows with `tenant_name` set - * (rows in pending/rejected/cancelled state don't have one and are - * irrelevant to this reconciliation): + * state. Three passes, walking only rows with `tenant_name` set: * * 1. provisioning → active: when a tenant CR's phase reaches Ready * or Running, the portal flips the row to active so the @@ -657,6 +660,15 @@ export async function deleteTenantRequest(id: string): Promise { * keep showing the "Your assistant is ready!" card forever. * Without this reconciliation the dashboard drifts from reality. * + * 3. pending resume → cancelled: when a pending resume request's + * tenant is no longer suspended (admin resumed it directly, + * tenant was deleted, or it was never suspended in the first + * place), the request is moot. Flip to 'cancelled' so the + * pending-resume unique index releases for any future genuine + * resume request. We pick `cancelled` over `rejected` because + * the customer didn't do anything wrong — circumstances just + * changed. + * * Errors are tolerated per-row: a transient API hiccup on one tenant * shouldn't fail the whole sweep. Skipped rows get retried next call. * @@ -666,12 +678,19 @@ export async function deleteTenantRequest(id: string): Promise { */ export async function syncProvisioningStatuses(): Promise { await ensureSchema(); - // Pull every row that *might* be reconcilable in one query — the - // status filter narrows to ones whose CR-vs-DB consistency is - // worth checking. Pending/rejected/cancelled rows have no - // tenant_name to compare against; deleted rows are terminal. + // Active+provisioning rows: status reflects "the tenant should + // exist and be running". + // Pending resume rows: status reflects "the tenant is suspended, + // awaiting reactivation". + // Both need cluster-side validation; we fetch them in one query + // and dispatch on (status, request_type). const result = await getPool().query( - "SELECT * FROM tenant_requests WHERE status IN ('provisioning', 'active') AND tenant_name IS NOT NULL" + `SELECT * FROM tenant_requests + WHERE tenant_name IS NOT NULL + AND ( + status IN ('provisioning', 'active') + OR (status = 'pending' AND request_type = 'resume') + )` ); for (const row of result.rows) { @@ -686,12 +705,36 @@ export async function syncProvisioningStatuses(): Promise { continue; } - // CR gone, or mid-deletion. Flip the row to 'deleted'. The - // `markTenantRequestDeletedByTenantName` helper also nulls the - // tenant_name column so any future tenant created with the same - // name (unlikely given UUID-suffixed naming, but possible) won't - // collide with the unique index on (tenant_name) WHERE - // request_type = 'provision'. + // Pending resume request: validity hinges on tenant being suspended. + if ( + mapped.status === "pending" && + mapped.requestType === "resume" + ) { + // Tenant doesn't exist or is being deleted: cancel the resume + // request (it can never be fulfilled). Don't fall through to + // the "deleted" branch below — that would also flip the + // provision row, which is the right thing for a CR-level + // deletion but we want this resume row specifically resolved + // here. + if (!tenant || tenant.metadata.deletionTimestamp) { + await updateTenantRequestStatus(mapped.id, "cancelled"); + continue; + } + // Tenant is no longer suspended: the request is moot. + // Cancel it (the customer didn't do anything wrong; the + // condition the request was about no longer applies). + if (!tenant.spec.suspend) { + await updateTenantRequestStatus(mapped.id, "cancelled"); + continue; + } + // Tenant still suspended, request still relevant. Leave as-is. + continue; + } + + // Active or provisioning row: CR gone, or mid-deletion. Flip the + // row to 'deleted'. `markTenantRequestDeletedByTenantName` flips + // every row with this tenant_name (provision + any resume rows), + // which is the right thing for a CR-level deletion. if (!tenant || tenant.metadata.deletionTimestamp) { await markTenantRequestDeletedByTenantName(mapped.tenantName); continue;