diff --git a/backend/.sqlx/query-3c1be20dfcfe86949a8073382efb5ae764046b218dfde6b4e7bbd3b9f42b7d4b.json b/backend/.sqlx/query-3c1be20dfcfe86949a8073382efb5ae764046b218dfde6b4e7bbd3b9f42b7d4b.json new file mode 100644 index 0000000000..830f9dd10a --- /dev/null +++ b/backend/.sqlx/query-3c1be20dfcfe86949a8073382efb5ae764046b218dfde6b4e7bbd3b9f42b7d4b.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT format('data table ''%s'' of ''%s''', d.key, ws.workspace_id) AS \"use!\"\n FROM workspace_settings ws,\n jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) d\n WHERE jsonb_typeof(d.value) = 'object'\n AND NOT (ws.workspace_id = $1 AND d.key = $2)\n AND ((d.value->'reference'->>'workspace_id' = $1\n AND d.value->'reference'->>'datatable' = $2)\n OR (d.value->'database'->>'resource_type' = 'instance'\n AND d.value->'database'->>'resource_path' = $3))\n UNION ALL\n SELECT format('Ducklake ''%s'' of ''%s''', l.key, ws.workspace_id)\n FROM workspace_settings ws,\n jsonb_each(COALESCE(ws.ducklake->'ducklakes', '{}'::jsonb)) l\n WHERE jsonb_typeof(l.value) = 'object'\n AND l.value->'catalog'->>'resource_type' = 'instance'\n AND l.value->'catalog'->>'resource_path' = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "use!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "3c1be20dfcfe86949a8073382efb5ae764046b218dfde6b4e7bbd3b9f42b7d4b" +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 185fe17b25..c772e57a7b 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3647,13 +3647,15 @@ async fn import_pg_database( resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.target).await?; if let Some(ref override_dbname) = req.target_dbname_override { + // A `wm_fork_` name does not say whose copy it is: every instance database answers to the + // same connection user, so an override would reach another workspace's fork copy. Forks + // make their own copies, so nothing but a superadmin needs one. if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { - if !override_dbname.starts_with("wm_fork_") { - return Err(Error::BadRequest( - "Non-superadmin users can only override target dbname with names starting with 'wm_fork_'" - .to_string(), - )); - } + return Err(Error::BadRequest( + "Only superadmins can override the target database: create forks through the \ + fork wizard or `wmill workspace fork`, which copy each data table themselves" + .to_string(), + )); } target_pg.dbname = override_dbname.clone(); } diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 9039bda2ef..51b479bfcf 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -1430,6 +1430,23 @@ pub async fn drop_forked_datatable_databases( _ => continue, }; + match forked_database_uses(&db, &w_id, dt_name, database).await { + Ok(uses) if uses.is_empty() => {} + Ok(uses) => { + errors.push(format!( + "Refusing to drop the database of datatable://{dt_name}: still used by {}", + uses.join(", ") + )); + continue; + } + Err(e) => { + errors.push(format!( + "Could not check what still uses the database of datatable://{dt_name}: {e}" + )); + continue; + } + } + if database.resource_type == windmill_common::workspaces::DataTableCatalogResourceType::Instance { @@ -1531,6 +1548,45 @@ pub async fn drop_forked_datatable_databases( Ok(Json(errors)) } +/// What besides data table `dt_name` of `w_id` still uses its database: a data table anywhere +/// pointing at that entry (a child fork keeping it), and for an instance database, any other data +/// table entry or Ducklake catalog naming it. Instance databases share one connection user, so +/// nothing but these settings tells a copy still in use from one that is not. +async fn forked_database_uses( + db: &DB, + w_id: &str, + dt_name: &str, + database: &windmill_common::workspaces::DataTableDatabase, +) -> Result> { + let instance_db = (database.resource_type + == windmill_common::workspaces::DataTableCatalogResourceType::Instance) + .then_some(database.resource_path.as_str()); + let uses = sqlx::query_scalar!( + r#"SELECT format('data table ''%s'' of ''%s''', d.key, ws.workspace_id) AS "use!" + FROM workspace_settings ws, + jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) d + WHERE jsonb_typeof(d.value) = 'object' + AND NOT (ws.workspace_id = $1 AND d.key = $2) + AND ((d.value->'reference'->>'workspace_id' = $1 + AND d.value->'reference'->>'datatable' = $2) + OR (d.value->'database'->>'resource_type' = 'instance' + AND d.value->'database'->>'resource_path' = $3)) + UNION ALL + SELECT format('Ducklake ''%s'' of ''%s''', l.key, ws.workspace_id) + FROM workspace_settings ws, + jsonb_each(COALESCE(ws.ducklake->'ducklakes', '{}'::jsonb)) l + WHERE jsonb_typeof(l.value) = 'object' + AND l.value->'catalog'->>'resource_type' = 'instance' + AND l.value->'catalog'->>'resource_path' = $3"#, + w_id, + dt_name, + instance_db, + ) + .fetch_all(db) + .await?; + Ok(uses) +} + /// Drop this fork workspace's ducklake namespaces: the `wm_fork_*` metadata schema in each /// lake's catalog database, plus (best effort) the fork's `__wm_forks//…` data files in /// the workspace storage. Driven by the `fork_ducklake_namespace` registry written at first diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 85ae9388c8..4b259c065c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -6159,7 +6159,7 @@ paths: description: "Target database: 'datatable://name' or '$res:path'" target_dbname_override: type: string - description: "Override the target database name" + description: "Override the target database name (superadmins only)" fork_behavior: type: string enum: