Compare commits

...
Author SHA1 Message Date
Diego Imbert 7577c55b13 Merge remote-tracking branch 'origin/datatable-external-instance-4' into datatable-external-instance-ui 2026-09-18 01:03:57 +02:00
Diego Imbert 51224582c1 Merge commit '2661dadba63efcc0c5db88a7942d66caf34f20b9' into HEAD
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-18 01:00:23 +02:00
Diego Imbert 2661dadba6 Merge commit '8c554ef1337aa6cd99ab869443dfd30becc65a36' into HEAD 2026-09-18 01:00:09 +02:00
Diego ImbertandClaude Opus 5 57211d6c95 fix(datatables): keep only the most recently used DuckDB root certificate files
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:55:35 +02:00
Diego Imbert 0dbf5eef74 Merge commit 'addd77eaf4' into HEAD
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-18 00:55:35 +02:00
Diego Imbert 8e780a7923 Merge remote-tracking branch 'origin/datatable-external-instance-4' into datatable-external-instance-ui 2026-09-17 18:25:57 +02:00
Diego Imbert 3fdb3047a7 Merge commit '5dfb2f30f74cedaae9f113383e42d3a35cde5189' into HEAD
# Conflicts:
#	backend/ee-repo-ref.txt
#	backend/windmill-common/src/datatable_roles.rs
2026-09-17 18:23:38 +02:00
Diego Imbert 5dfb2f30f7 Merge commit '76a10f8ee8' into HEAD
# Conflicts:
#	backend/ee-repo-ref.txt
#	backend/windmill-common/src/lib.rs
2026-09-17 18:22:16 +02:00
Diego Imbert 4a0d7c0c29 Merge commit 'd62807e9edc553b1eb08c10ce28521a71007a263' into HEAD 2026-09-17 18:04:22 +02:00
Diego ImbertandClaude Opus 5 d62807e9ed fix(datatables): stop counting storage-only fork cleanup rows as uses of an external database
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:04:19 +02:00
Diego Imbert 696b4ab5f8 Merge remote-tracking branch 'origin/datatable-external-instance-4' into datatable-external-instance-ui 2026-09-17 16:37:27 +02:00
Diego ImbertandClaude Opus 5 48f026eb0f fix(datatables): refuse rolling back while external data tables are under roles, and type external_instance in the CLI
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:35:45 +02:00
Diego Imbert 0e659898f3 Merge commit 'a296ca016a' into HEAD
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-17 16:35:45 +02:00
Diego Imbert a296ca016a Merge commit '7c16d482fd' into HEAD
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-17 16:34:50 +02:00
Diego ImbertandClaude Opus 5 947e44fe49 fix(settings): show the server's reason when saving instance settings fails
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:26:29 +02:00
Diego Imbert 489493dba2 Merge remote-tracking branch 'origin/datatable-external-instance-4' into datatable-external-instance-ui 2026-09-17 16:18:12 +02:00
Diego Imbert c55ff3141c Merge commit '5eec203099ba1034039f8b52e81400078cac919d' into HEAD 2026-09-17 16:17:18 +02:00
Diego ImbertandClaude Opus 5 5eec203099 fix(datatables): keep certificate verification when DuckDB attaches an external data table
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:17:15 +02:00
Diego ImbertandClaude Opus 5 0b3882f2b2 fix(datatables): keep certificate verification when DuckDB attaches an external data table
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:17:02 +02:00
Diego Imbert 58731b1828 Merge commit '53d62dd399' into HEAD
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-17 16:17:02 +02:00
Diego ImbertandClaude Opus 5 5624d350d5 feat(datatables): manage the external instance cluster's data table roles from the UI
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:09:25 +02:00
Diego ImbertandClaude Opus 5 53d62dd399 fix(datatables): register external fork catalogs under the lifecycle lock, and keep certificate verification in DuckDB attaches
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:07:40 +02:00
Diego Imbert e17b76c255 Merge commit '5ddaee5b78' into HEAD
# Conflicts:
#	backend/ee-repo-ref.txt
2026-09-17 16:07:40 +02:00
Diego Imbert cfada61f41 Merge remote-tracking branch 'origin/datatable-external-instance-4' into datatable-external-instance-ui 2026-09-17 16:00:06 +02:00
Diego ImbertandClaude Opus 5 0406f133c5 feat(datatables): per-cluster data table role catalogs, with roles on the external instance cluster
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:59:22 +02:00
Diego ImbertandClaude Opus 5 aebae79397 feat(datatables): configure the external instance cluster and pick its databases from the UI
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:45:22 +02:00
Diego ImbertandClaude Opus 5 b62d8b246a fix(datatables): protect external databases pending fork cleanup, and describe Ducklake usage in the API
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:30:25 +02:00
Diego Imbert 5bc2f13565 Merge commit '3981b2eb1b3bf77cdc1a5cbf43090e6b9706aa1f' into datatable-external-instance-3 2026-09-17 15:28:31 +02:00
Diego Imbert 0c53990d18 Merge commit '9b0046d8671697e6f6568c89170052367139d157' into datatable-external-instance-3
# Conflicts:
#	backend/ee-repo-ref.txt
#	backend/windmill-common/src/external_instance_pg.rs
2026-09-17 11:50:21 +02:00
Diego ImbertandClaude Opus 5 67f488c676 feat(datatables): Ducklake catalogs on the external instance cluster
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:47:25 +02:00
32 changed files with 1299 additions and 375 deletions
@@ -1,38 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name, enabled, pwd FROM datatable_role",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "enabled",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "pwd",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0"
}
@@ -1,29 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE ws.workspace_id <> $1\n AND dt.value->'database'->>'resource_type' = 'instance'\n AND dt.value->'database'->>'resource_path' = $2\n ORDER BY ws.workspace_id, dt.key\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "datatable!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
null
]
},
"hash": "79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629"
}
@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO datatable_role (id, name, enabled, pwd) VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT jsonb_object_keys(value->'databases') FROM global_settings\n WHERE name = 'custom_instance_pg_databases'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "jsonb_object_keys",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6"
}
@@ -1,24 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE ws.workspace_id = $1\n AND dt.key <> $2\n AND NOT dt.value ? 'permissions'\n AND dt.value->'database'->>'resource_type' = 'instance'\n AND dt.value->'database'->>'resource_path' = $3\n ORDER BY dt.key",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "datatable!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40"
}
+1 -1
View File
@@ -1 +1 @@
bf8f77b076599124ad8bb1ebb8f28f1509b7d70e
151be033ea2bf2f768f381a8cd3b888bdd366cf9
@@ -0,0 +1,22 @@
-- Roles on the external cluster are live logins there; dropping the column would forget them.
LOCK TABLE datatable_role;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM datatable_role WHERE cluster <> 'instance') THEN
RAISE EXCEPTION 'datatable_role holds roles on the external instance cluster. Delete them in instance settings first.';
END IF;
-- Before this, only data tables on Windmill's own cluster could be under roles, and a role
-- block left with just `admin` survives deleting every external role.
IF EXISTS (
SELECT 1 FROM workspace_settings ws,
jsonb_each(CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object'
THEN ws.datatable->'datatables' ELSE '{}'::jsonb END) dt
WHERE dt.value->'database'->>'resource_type' = 'external_instance'
AND dt.value ? 'permissions'
) THEN
RAISE EXCEPTION 'external instance data tables are still under roles. Turn their roles off first.';
END IF;
END $$;
ALTER TABLE datatable_role DROP CONSTRAINT datatable_role_cluster_name_key;
ALTER TABLE datatable_role ADD CONSTRAINT datatable_role_name_key UNIQUE (name);
ALTER TABLE datatable_role DROP COLUMN cluster;
@@ -0,0 +1,8 @@
-- A data table role is a Postgres login on one cluster: Windmill's own ('instance'), or the external
-- instance cluster ('external_instance'). Role names are the cluster's own key, so they are unique
-- per cluster rather than across the instance.
ALTER TABLE datatable_role
ADD COLUMN cluster VARCHAR(20) NOT NULL DEFAULT 'instance'
CHECK (cluster IN ('instance', 'external_instance'));
ALTER TABLE datatable_role DROP CONSTRAINT datatable_role_name_key;
ALTER TABLE datatable_role ADD CONSTRAINT datatable_role_cluster_name_key UNIQUE (cluster, name);
@@ -391,7 +391,11 @@ async fn concurrent_role_creations_both_survive(db: Pool<Postgres>) -> anyhow::R
assert_eq!(a.0, 200, "{}", a.1);
assert_eq!(b.0, 200, "{}", b.1);
let catalog = windmill_common::datatable_roles::read_role_catalog(&db).await?;
let catalog = windmill_common::datatable_roles::read_role_catalog(
&db,
windmill_common::datatable_roles::DatatableRoleCluster::Instance,
)
.await?;
let recorded: Vec<&str> = catalog.values().map(|r| r.name.as_str()).collect();
for name in &names {
assert!(
@@ -460,7 +464,11 @@ async fn a_role_delete_that_fails_part_way_leaves_the_role_disabled(
let body = resp.text().await?;
assert_eq!(status, 400, "{body}");
let catalog = windmill_common::datatable_roles::read_role_catalog(&db).await?;
let catalog = windmill_common::datatable_roles::read_role_catalog(
&db,
windmill_common::datatable_roles::DatatableRoleCluster::Instance,
)
.await?;
let role = catalog
.get(&id)
.expect("a failed delete keeps the entry to retry");
@@ -6,7 +6,7 @@
* LICENSE-AGPL for a copy of the license.
*/
//! Ownership and grants on the objects of an instance data table.
//! Ownership and grants on the objects of a data table on a cluster Windmill manages.
//!
//! [`datatable_permissions`](crate::datatable_permissions) decides who may connect as which role;
//! this decides what each role may then touch. Every change is a real `GRANT`, `REVOKE`,
@@ -33,7 +33,7 @@ use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::datatable_roles::{
lock_role_catalog, quote_ident, read_role_catalog, read_role_catalog_tx, DatatableRoleCatalog,
ADMIN_DATATABLE_ROLE, CUSTOM_INSTANCE_USER,
DatatableRoleCluster, ADMIN_DATATABLE_ROLE, CUSTOM_INSTANCE_USER,
};
use windmill_common::error::{pg_error_message, Error, JsonResult, Result};
use windmill_common::workspaces::{resolve_governing_datatable, DataTable, GoverningDatatable};
@@ -295,22 +295,22 @@ fn role_names(catalog: &DatatableRoleCatalog) -> Vec<String> {
names
}
fn ensure_instance(governing: &GoverningDatatable) -> Result<()> {
if governing.is_instance() {
return Ok(());
}
Err(Error::BadRequest(format!(
"Data table '{}' is backed by a Postgres resource, so its access is managed on that \
server directly. Only a data table on the Windmill instance's own database has data \
table roles to grant to.",
governing.name
)))
/// The cluster whose roles the data table's grants name.
fn ensure_managed(governing: &GoverningDatatable) -> Result<DatatableRoleCluster> {
governing.role_cluster().ok_or_else(|| {
Error::BadRequest(format!(
"Data table '{}' is backed by a Postgres resource, so its access is managed on that \
server directly. Only a data table on a database Windmill manages has data table \
roles to grant to.",
governing.name
))
})
}
/// The data table's `admin` connection, and the notices Postgres sends on it.
///
/// Authorization: connects as `custom_instance_user` with the instance's own credentials and checks
/// nothing. Callers MUST have authorized the request first — a request about to be refused must
/// Authorization: connects as `custom_instance_user` with the cluster's stored credentials and
/// checks nothing. Callers MUST have authorized the request first — a request about to be refused must
/// not get as far as this connection.
async fn connect_as_admin_unchecked(
db: &DB,
@@ -320,21 +320,33 @@ async fn connect_as_admin_unchecked(
mpsc::UnboundedReceiver<DbError>,
String,
)> {
ensure_instance(governing)?;
let cluster = ensure_managed(governing)?;
// Built from the authorized entry, never by resolving the settings again: a save in between
// could point the entry at a resource on another server and back, and this connection would
// then alter a database the later checks of the entry never see.
let mut pg = PgDatabase::parse_uri(&windmill_common::get_database_url().await?.as_str().await)?;
pg.dbname = governing
let dbname = governing
.datatable
.database
.as_ref()
.expect("a governing entry owns a database")
.resource_path
.clone();
pg.user = Some(CUSTOM_INSTANCE_USER.to_string());
pg.password = Some(windmill_common::utils::get_custom_pg_instance_password(db).await?);
let dbname = pg.dbname.clone();
let pg = match cluster {
DatatableRoleCluster::Instance => {
let mut pg =
PgDatabase::parse_uri(&windmill_common::get_database_url().await?.as_str().await)?;
pg.dbname = dbname.clone();
pg.user = Some(CUSTOM_INSTANCE_USER.to_string());
pg.password = Some(windmill_common::utils::get_custom_pg_instance_password(db).await?);
pg
}
DatatableRoleCluster::ExternalInstance => {
windmill_common::external_instance_pg::external_instance_connection_unchecked(
db, &dbname, false,
)
.await?
}
};
let (client, mut connection) = pg.connect(Some(db)).await?;
// Unbounded: the driver must never wait on the receiver, which only drains once the statement
// the driver is carrying has completed.
@@ -1020,12 +1032,12 @@ async fn get_datatable_acl(
let target: AclTarget = query.try_into()?;
let governing = resolve_governing_datatable(&db, &w_id, &datatable_name).await?;
ensure_reaches_governing_datatable(&db, &w_id, &datatable_name, &governing, &authed).await?;
ensure_instance(&governing)?;
let cluster = ensure_managed(&governing)?;
let editable = ensure_governs_datatable(&db, &authed, &w_id, &governing)
.await
.is_ok();
let roles = if editable {
role_names(&read_role_catalog(&db).await?)
role_names(&read_role_catalog(&db, cluster).await?)
} else {
vec![]
};
@@ -1310,7 +1322,7 @@ async fn authorize_acl_change(
) -> Result<GoverningDatatable> {
let governing = resolve_governing_datatable(db, w_id, datatable_name).await?;
ensure_governs_datatable(db, authed, w_id, &governing).await?;
ensure_instance(&governing)?;
ensure_managed(&governing)?;
Ok(governing)
}
@@ -1320,8 +1332,14 @@ static APPLY_SLOT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1)
/// provisioned before data table roles gave `custom_instance_user` none. Adds that option to its
/// database and `public` privileges, and nothing else: default privileges are left alone, since a
/// schema's change of owner is planned against them. Best-effort, as a grant it fails to enable is
/// refused when it runs.
async fn ensure_grant_options(client: &tokio_postgres::Client, db: &DB, dbname: &str) {
/// refused when it runs. An external instance database was created with the options, so one
/// missing there is someone's deliberate revoke and is left alone.
async fn ensure_grant_options(
client: &tokio_postgres::Client,
db: &DB,
cluster: DatatableRoleCluster,
dbname: &str,
) {
let held = client
.query_one(
"SELECT has_database_privilege(current_database(), 'CONNECT WITH GRANT OPTION')
@@ -1333,7 +1351,7 @@ async fn ensure_grant_options(client: &tokio_postgres::Client, db: &DB, dbname:
)
.await
.is_ok_and(|row| row.get::<_, bool>(0));
if held {
if held || cluster != DatatableRoleCluster::Instance {
return;
}
if let Err(e) = grant_options_as_server(db, dbname).await {
@@ -1559,7 +1577,8 @@ async fn plan_datatable_acl(
) -> JsonResult<AclPlan> {
crate::datatable_acl_oss::ensure_datatable_acl_available()?;
let governing = authorize_acl_change(&db, &authed, &w_id, &datatable_name).await?;
let catalog = read_role_catalog(&db).await?;
let cluster = ensure_managed(&governing)?;
let catalog = read_role_catalog(&db, cluster).await?;
let (client, _notices, dbname) = connect_as_admin_unchecked(&db, &governing).await?;
Ok(Json(
build_plan(&client, &dbname, &catalog, &req.target, &req.change).await?,
@@ -1583,6 +1602,7 @@ async fn apply_datatable_acl(
// connection could wait forever on a pool that concurrent applies, queued on the same locks,
// have exhausted.
let governing = authorize_acl_change(&db, &authed, &w_id, &datatable_name).await?;
let cluster = ensure_managed(&governing)?;
// Applies queue on an instance-wide lock while each holds a direct connection to the instance's
// Postgres; unbounded, the queue alone could exhaust its connection limit. One at a time per
// server, and the ones waiting hold no connection at all.
@@ -1591,7 +1611,7 @@ async fn apply_datatable_acl(
.await
.map_err(|e| Error::internal_err(format!("ACL apply slot closed: {e}")))?;
let (mut client, mut notices, dbname) = connect_as_admin_unchecked(&db, &governing).await?;
ensure_grant_options(&client, &db, &dbname).await;
ensure_grant_options(&client, &db, cluster, &dbname).await;
// Held until the change is committed: a role renamed or dropped meanwhile would change what
// the plan names, and a settings save could move the entry onto another database. Taken in the
@@ -1606,7 +1626,7 @@ async fn apply_datatable_acl(
.fetch_optional(&mut *tx)
.await?
.flatten();
let catalog = read_role_catalog_tx(&mut tx).await?;
let catalog = read_role_catalog_tx(&mut tx, cluster).await?;
let plan = build_plan(&client, &dbname, &catalog, &req.target, &req.change).await?;
if !entry_unchanged(&governing, entry_now) || &plan.statements != confirmed {
@@ -2263,7 +2263,8 @@ struct DataTableTables {
schemas: TableListMap,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// On the instance database: the only kind that can be under roles or have its access edited.
/// On a database Windmill manages, on its own cluster or the external one: the only kinds that
/// can be under roles or have their access edited.
instance: bool,
permissioned: bool,
/// The roles this caller may connect as, by name; empty when not under roles.
@@ -2520,7 +2521,7 @@ async fn list_one_datatable_tables(
};
let result: Result<()> = async {
let governing = resolve_governing_datatable(db, w_id, &entry.datatable_name).await?;
entry.instance = governing.is_instance();
entry.instance = governing.role_cluster().is_some();
let usable =
crate::datatable_permissions_oss::usable_datatable_roles(db, authed, w_id, &governing)
.await?;
@@ -3370,7 +3371,7 @@ fn apply_pg_tls_env(
cmd.env("PGSSLROOTCERT", "system");
}
Some("verify-ca") => {
if let Some(bundle) = system_ca_bundle() {
if let Some(bundle) = windmill_common::system_ca_bundle() {
cmd.env("PGSSLROOTCERT", bundle);
}
}
@@ -3380,21 +3381,6 @@ fn apply_pg_tls_env(
Ok(None)
}
fn system_ca_bundle() -> Option<std::path::PathBuf> {
std::env::var_os("SSL_CERT_FILE")
.map(std::path::PathBuf::from)
.into_iter()
.chain(
[
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/cert.pem",
"/etc/ssl/ca-bundle.pem",
]
.map(std::path::PathBuf::from),
)
.find(|path| path.is_file())
}
#[cfg(test)]
mod pg_tls_env_tests {
@@ -3843,22 +3829,37 @@ async fn edit_ducklake_config(
let old_ducklakes: HashMap<String, Ducklake> =
serde_json::from_value(old_ducklakes).unwrap_or_default();
// Check that non-superadmins are not abusing Instance databases
if !is_superadmin {
for (name, dl) in new_config.settings.ducklakes.iter() {
if dl.catalog.resource_type == DucklakeCatalogResourceType::Instance {
let old_dl = old_ducklakes.get(name);
if old_dl.is_none()
|| old_dl.unwrap().catalog.resource_type
!= DucklakeCatalogResourceType::Instance
|| old_dl.unwrap().catalog.resource_path != dl.catalog.resource_path
{
return Err(Error::BadRequest(
"Only superadmins can create or modify ducklakes with Instance databases"
.to_string(),
));
}
}
// Check that non-superadmins are not abusing Instance databases. An unchanged catalog is left
// alone either way, so a downgraded instance can still save lakes that already name an
// external instance database.
for (name, dl) in new_config.settings.ducklakes.iter() {
let kind = &dl.catalog.resource_type;
if !matches!(
kind,
DucklakeCatalogResourceType::Instance | DucklakeCatalogResourceType::ExternalInstance
) {
continue;
}
let unchanged = old_ducklakes.get(name).is_some_and(|old| {
&old.catalog.resource_type == kind
&& old.catalog.resource_path == dl.catalog.resource_path
});
if unchanged {
continue;
}
if *kind == DucklakeCatalogResourceType::ExternalInstance {
windmill_common::external_instance_pg::ensure_external_instance_available()?;
windmill_common::external_instance_pg::ensure_external_instance_database_registered(
&mut tx,
&dl.catalog.resource_path,
)
.await?;
}
if !is_superadmin {
return Err(Error::BadRequest(
"Only superadmins can create or modify ducklakes with Instance databases"
.to_string(),
));
}
}
@@ -4050,18 +4051,20 @@ async fn edit_datatable_config(
None => None,
};
// Carrying the block onto a resource-backed entry would produce a data table the chokepoint
// refuses on every job — a save that succeeds and breaks everything afterwards. Refuse it
// instead: turning roles off first is one step, and it keeps discarding an access decision
// something somebody chose rather than a side effect of moving a database.
// refuses on every job — a save that succeeds and breaks everything afterwards — and onto
// the other managed cluster, one whose role ids name nothing in that cluster's catalog.
// Refuse it instead: turning roles off first is one step, and it keeps discarding an access
// decision something somebody chose rather than a side effect of moving a database.
let old_kind = old.and_then(|old| old.database.as_ref()).map(|d| d.resource_type);
if dt.permissions.is_some()
&& dt
.database
.as_ref()
.is_some_and(|d| d.resource_type != DataTableCatalogResourceType::Instance)
.is_some_and(|d| Some(d.resource_type) != old_kind)
{
return Err(Error::BadRequest(format!(
"Data table '{name}' is under roles, which only a data table on the instance \
database can be. Turn its roles off before moving it to a PostgreSQL resource."
"Data table '{name}' is under roles, which belong to the cluster its database is \
on. Turn its roles off before moving it to another kind of database."
)));
}
// A pointer names no database of its own, so the form's empty `database` is correct there.
@@ -4138,7 +4141,7 @@ async fn edit_datatable_config(
// entry through a declared rename alone, and a settings sync never declares one, so an entry
// without roles that newly points at such a database — a name added, or an existing one
// repointed — would answer everyone there as `admin`. That holds whichever workspace governs it.
let newly_pointed: Vec<(&String, &str)> = new_config
let newly_pointed: Vec<(&String, DataTableCatalogResourceType, &str)> = new_config
.settings
.datatables
.iter()
@@ -4147,7 +4150,7 @@ async fn edit_datatable_config(
let db = dt
.database
.as_ref()
.filter(|d| d.resource_type == DataTableCatalogResourceType::Instance)?;
.filter(|d| d.resource_type.is_windmill_managed())?;
let lookup = rename_src
.get(name.as_str())
.copied()
@@ -4159,38 +4162,44 @@ async fn edit_datatable_config(
old_db.resource_type != db.resource_type
|| old_db.resource_path != db.resource_path
});
repointed.then_some((name, db.resource_path.as_str()))
repointed.then_some((name, db.resource_type, db.resource_path.as_str()))
})
.collect();
// Another workspace turning roles on for the same database holds only its own settings row, so
// without this the scan below could read past its uncommitted write.
windmill_common::datatable_roles::lock_instance_databases_governance(
&mut *tx,
newly_pointed.iter().map(|(_, dbname)| *dbname),
newly_pointed.iter().map(|(_, _, dbname)| *dbname),
)
.await?;
let governed_elsewhere: Vec<String> = if newly_pointed.is_empty() {
let governed_elsewhere: Vec<(String, String)> = if newly_pointed.is_empty() {
vec![]
} else {
sqlx::query_scalar(
"SELECT DISTINCT dt.value->'database'->>'resource_path' FROM workspace_settings ws
sqlx::query_as(
"SELECT DISTINCT dt.value->'database'->>'resource_type',
dt.value->'database'->>'resource_path'
FROM workspace_settings ws
CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt
WHERE ws.workspace_id <> $1 AND dt.value ? 'permissions'
AND dt.value->'database'->>'resource_type' = 'instance'",
AND dt.value->'database'->>'resource_type' IN ('instance', 'external_instance')",
)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?
};
for (name, dbname) in newly_pointed {
for (name, kind, dbname) in newly_pointed {
let governed_here = old_datatables.values().any(|old| {
old.permissions.is_some()
&& old.database.as_ref().is_some_and(|d| {
d.resource_type == DataTableCatalogResourceType::Instance
&& d.resource_path == dbname
})
&& old
.database
.as_ref()
.is_some_and(|d| d.resource_type == kind && d.resource_path == dbname)
});
if governed_here || governed_elsewhere.iter().any(|g| g == dbname) {
if governed_here
|| governed_elsewhere
.iter()
.any(|(k, p)| k == kind.as_ref() && p == dbname)
{
return Err(Error::BadRequest(format!(
"Data table '{name}' would point at database '{dbname}', which a data table under \
roles uses, without carrying those roles: everyone reaching '{name}' would connect \
@@ -1835,7 +1835,17 @@ async fn resolve_fork_catalog_pg(
"ducklake://{ducklake_name}: malformed registry catalog identity `{catalog}`"
))
})?;
let catalog_resource = if resource_type == "instance" {
let catalog_resource = if resource_type == "external_instance" {
serde_json::to_value(
windmill_common::external_instance_pg::external_instance_connection_unchecked(
db,
resource_path,
false,
)
.await?,
)
.map_err(|e| Error::internal_err(format!("serializing pg creds: {e}")))?
} else if resource_type == "instance" {
let mut pg_creds = windmill_common::PgDatabase::parse_uri(
&windmill_common::get_database_url().await?.as_str().await,
)?;
+29 -8
View File
@@ -1611,7 +1611,7 @@ paths:
/settings/external_instance_pg/databases:
get:
summary: Lists the databases Windmill created on the external instance cluster, with the workspaces whose data tables use each
summary: Lists the databases Windmill created on the external instance cluster, with the workspaces whose data tables, Ducklake catalogs or pending fork cleanups use each
operationId: listExternalInstancePgDatabases
tags:
- setting
@@ -1653,7 +1653,7 @@ paths:
application/json:
schema: {}
delete:
summary: Drops a database Windmill created on the external instance cluster, refused while a data table uses it
summary: Drops a database Windmill created on the external instance cluster, refused while a data table, Ducklake catalog or pending fork cleanup uses it
operationId: dropExternalInstancePgDatabase
tags:
- setting
@@ -1688,13 +1688,19 @@ paths:
/settings/datatable_roles:
get:
summary: list the instance's data table roles
summary: list the data table roles of one Windmill-managed Postgres cluster
operationId: listInstanceDatatableRoles
tags:
- setting
parameters:
- in: query
name: cluster
required: false
schema:
$ref: "#/components/schemas/DatatableRoleCluster"
responses:
"200":
description: the instance role catalog
description: the cluster's role catalog
content:
application/json:
schema:
@@ -1702,7 +1708,7 @@ paths:
items:
$ref: "#/components/schemas/InstanceDatatableRole"
post:
summary: create a data table role on the instance's Postgres cluster
summary: create a data table role on a Windmill-managed Postgres cluster
operationId: createInstanceDatatableRole
tags:
- setting
@@ -1716,6 +1722,8 @@ paths:
properties:
name:
type: string
cluster:
$ref: "#/components/schemas/DatatableRoleCluster"
responses:
"200":
description: the created role
@@ -33757,9 +33765,17 @@ components:
last_setup:
$ref: "#/components/schemas/ExternalInstancePgSetupReport"
DatatableRoleCluster:
type: string
description: >-
The Windmill-managed Postgres cluster a data table role is a login on: Windmill's own
(behind `instance` data tables) or the external instance cluster (behind
`external_instance` ones). Defaults to `instance`.
enum: [instance, external_instance]
InstanceDatatableRole:
type: object
required: [id, name, enabled]
required: [id, name, enabled, cluster]
properties:
id:
type: string
@@ -33767,6 +33783,8 @@ components:
type: string
enabled:
type: boolean
cluster:
$ref: "#/components/schemas/DatatableRoleCluster"
DatatableRoleTenants:
type: object
@@ -33788,8 +33806,10 @@ components:
supported:
type: boolean
description: >-
Whether this data table can be put under roles at all. Only one backed by the
instance database can: a role is a login on that cluster.
Whether this data table can be put under roles at all. Only one on a database Windmill
manages can: a role is a login on that database's cluster.
cluster:
$ref: "#/components/schemas/DatatableRoleCluster"
permissioned:
type: boolean
default_role:
@@ -36066,6 +36086,7 @@ components:
- postgresql
- mysql
- instance
- external_instance
resource_path:
type: string
required:
+127 -44
View File
@@ -6,22 +6,67 @@
* LICENSE-AGPL for a copy of the license.
*/
//! The instance's data table role catalog.
//! The instance's data table role catalogs.
//!
//! A data table role is a real Postgres login role on the Windmill cluster, named exactly as the
//! user named it, shared by every instance database. Windmill decides who may ask for a role (the
//! per-data-table tenant lists in [`crate::workspaces`]); Postgres decides what the role may then
//! touch. The catalog here is only the first half's vocabulary plus the cluster provisioning.
//! A data table role is a real Postgres login role on one cluster — Windmill's own, or the external
//! instance cluster — named exactly as the user named it, shared by every database Windmill manages
//! on that cluster. Each cluster has its own catalog: a role exists where it was created and nowhere
//! else. Windmill decides who may ask for a role (the per-data-table tenant lists in
//! [`crate::workspaces`]); Postgres decides what the role may then touch. The catalog here is only
//! the first half's vocabulary plus the cluster provisioning.
//!
//! Entries are keyed by a generated id so a rename moves nothing else: tenants name the id.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{
error::{Error, Result},
workspaces::DataTableCatalogResourceType,
DB,
};
/// The cluster a role catalog belongs to.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DatatableRoleCluster {
/// Windmill's own Postgres, behind `instance` data tables.
#[default]
Instance,
/// The external instance cluster, behind `external_instance` data tables.
ExternalInstance,
}
impl DatatableRoleCluster {
pub fn as_str(self) -> &'static str {
match self {
Self::Instance => "instance",
Self::ExternalInstance => "external_instance",
}
}
pub fn parse(value: &str) -> Result<Self> {
match value {
"instance" => Ok(Self::Instance),
"external_instance" => Ok(Self::ExternalInstance),
other => Err(Error::BadRequest(format!(
"Unknown data table role cluster '{other}': expected instance or external_instance"
))),
}
}
/// The cluster whose roles a data table on `kind` can use. `None` for a resource-backed one,
/// which is never under roles.
pub fn of(kind: DataTableCatalogResourceType) -> Option<Self> {
match kind {
DataTableCatalogResourceType::Instance => Some(Self::Instance),
DataTableCatalogResourceType::ExternalInstance => Some(Self::ExternalInstance),
DataTableCatalogResourceType::Postgresql => None,
}
}
}
/// The connection every data table resolved to before roles existed (`custom_instance_user`). It
/// owns every pre-existing object, so it is a reserved name rather than a catalog entry: never
/// created, renamed or dropped.
@@ -164,28 +209,42 @@ pub async fn lock_instance_databases_governance<'a>(
/// need the names — but callers MUST NOT let `pwd` reach a response, a log line, an audit record
/// or an export. Nothing about who may call it: the credential is the whole risk, and `Debug` is
/// hand-written to redact it for the same reason.
pub async fn read_role_catalog(db: &DB) -> Result<DatatableRoleCatalog> {
crate::datatable_roles_oss::read_role_catalog(db).await
pub async fn read_role_catalog(
db: &DB,
cluster: DatatableRoleCluster,
) -> Result<DatatableRoleCatalog> {
crate::datatable_roles_oss::read_role_catalog(db, cluster).await
}
/// As [`read_role_catalog`], reading inside the caller's transaction so the value is the one
/// [`lock_role_catalog`] is protecting. Same disclosure contract.
pub async fn read_role_catalog_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
cluster: DatatableRoleCluster,
) -> Result<DatatableRoleCatalog> {
crate::datatable_roles_oss::read_role_catalog_tx(tx).await
crate::datatable_roles_oss::read_role_catalog_tx(tx, cluster).await
}
/// Record a role, in the caller's transaction so it commits with the `CREATE ROLE` it describes.
/// The cluster a role belongs to, or `None` if no role has this id.
pub async fn role_cluster(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: &str,
) -> Result<Option<DatatableRoleCluster>> {
crate::datatable_roles_oss::role_cluster(tx, id).await
}
/// Record a role, in the caller's transaction. On Windmill's own cluster that commits it with the
/// `CREATE ROLE` it describes; on the external cluster the role already exists by then.
///
/// Authorization: writes a generated Postgres credential. Callers MUST restrict this to superadmin
/// paths and MUST hold [`lock_role_catalog`] on `tx`.
pub async fn insert_role_catalog_entry(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: &str,
cluster: DatatableRoleCluster,
role: &InstanceDatatableRole,
) -> Result<()> {
crate::datatable_roles_oss::insert_role_catalog_entry(tx, id, role).await
crate::datatable_roles_oss::insert_role_catalog_entry(tx, id, cluster, role).await
}
/// Update a role's recorded name, login flag and password. Same contract as
@@ -215,7 +274,7 @@ pub fn role_id_by_name<'a>(catalog: &'a DatatableRoleCatalog, name: &str) -> Res
.find(|(_, role)| role.name == name)
.ok_or_else(|| {
Error::NotFound(format!(
"'{name}' is not a data table role of this instance. Defined roles: {}.",
"'{name}' is not a data table role of this database's cluster. Defined roles: {}.",
catalog
.values()
.map(|r| r.name.as_str())
@@ -231,70 +290,90 @@ pub fn role_id_by_name<'a>(catalog: &'a DatatableRoleCatalog, name: &str) -> Res
Ok(entry.0.as_str())
}
/// Every instance database the registry knows about. Role provisioning has to reach all of them:
/// a role that cannot `CONNECT` to a database is refused by Postgres before any grant matters.
/// Every database Windmill manages on `cluster`. Role provisioning has to reach all of them: a role
/// that cannot `CONNECT` to a database is refused by Postgres before any grant matters.
///
/// Authorization: checks nothing, and names every instance database across all workspaces. Callers
/// Authorization: checks nothing, and names every managed database across all workspaces. Callers
/// MUST be superadmin-gated or keep the names server-side; never return them to a workspace caller.
pub async fn registered_instance_databases(db: &DB) -> Result<Vec<String>> {
crate::datatable_roles_oss::registered_instance_databases(db).await
pub async fn registered_instance_databases(
db: &DB,
cluster: DatatableRoleCluster,
) -> Result<Vec<String>> {
crate::datatable_roles_oss::registered_instance_databases(db, cluster).await
}
/// `CONNECT` on `dbname` for every enabled role, and none for `PUBLIC`. Run at role creation, at
/// database creation, and lazily whenever an instance data table is administered, so a database
/// provisioned before a role existed is repaired rather than left silently unreachable.
/// `CONNECT` on `dbname` for every enabled role of `cluster`, and none for `PUBLIC`. Run at role
/// creation, at database creation, and lazily whenever a managed data table is administered, so a
/// database provisioned before a role existed is repaired rather than left silently unreachable.
///
/// Authorization: rewrites a database's ACL with the server's own credentials and checks nothing.
/// Callers MUST have authorized administration of `dbname` — superadmin, or an admin of the
/// workspace governing a data table on it.
pub async fn converge_connect_grants(db: &DB, dbname: &str) -> Result<()> {
crate::datatable_roles_oss::converge_connect_grants(db, dbname).await
pub async fn converge_connect_grants(
db: &DB,
cluster: DatatableRoleCluster,
dbname: &str,
) -> Result<()> {
crate::datatable_roles_oss::converge_connect_grants(db, cluster, dbname).await
}
/// As [`converge_connect_grants`], with a catalog the caller already read. Same contract.
/// As [`converge_connect_grants`], with the catalog of `cluster` the caller already read. Same
/// contract.
pub async fn converge_connect_grants_with(
db: &DB,
cluster: DatatableRoleCluster,
dbname: &str,
catalog: &DatatableRoleCatalog,
) -> Result<()> {
crate::datatable_roles_oss::converge_connect_grants_with(db, dbname, catalog).await
crate::datatable_roles_oss::converge_connect_grants_with(db, cluster, dbname, catalog).await
}
/// `CREATE ROLE <name> LOGIN PASSWORD ...; GRANT <name> TO custom_instance_user`, and `CONNECT` on
/// every registered database. No privileges beyond that — an admin grants them through SQL or the
/// ACL editor.
/// `CREATE ROLE <name> LOGIN PASSWORD ...; GRANT <name> TO custom_instance_user` on `cluster`. No
/// privileges beyond that — an admin grants them through SQL or the ACL editor.
///
/// On Windmill's own cluster the DDL runs on `tx`, so it commits with the catalog row. The external
/// cluster is another server: the role is created there before `tx` commits, and callers MUST drop
/// it again ([`drop_datatable_role`]) if `tx` then fails to commit.
///
/// Authorization: creates a cluster-wide Postgres login. Callers MUST restrict this to superadmin
/// paths, and MUST hold [`lock_role_catalog`] on the same transaction.
pub async fn create_instance_role(
/// paths, and MUST hold [`lock_role_catalog`] on `tx`.
pub async fn create_datatable_role(
db: &DB,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
cluster: DatatableRoleCluster,
name: &str,
password: &str,
) -> Result<()> {
crate::datatable_roles_oss::create_instance_role(tx, name, password).await
crate::datatable_roles_oss::create_datatable_role(db, tx, cluster, name, password).await
}
/// Authorization: alters a cluster-wide Postgres login. Callers MUST restrict this to superadmin
/// paths, and MUST hold [`lock_role_catalog`] on the same transaction.
pub async fn set_instance_role_login(
/// paths, and MUST hold [`lock_role_catalog`] on `tx`.
pub async fn set_datatable_role_login(
db: &DB,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
cluster: DatatableRoleCluster,
name: &str,
enabled: bool,
) -> Result<()> {
crate::datatable_roles_oss::set_instance_role_login(tx, name, enabled).await
crate::datatable_roles_oss::set_datatable_role_login(db, tx, cluster, name, enabled).await
}
/// A rename discards an md5-hashed password, so the caller has to hand over a fresh one.
/// A rename discards an md5-hashed password, so the caller has to hand over a fresh one. On the
/// external cluster the rename lands before `tx` commits, and callers MUST rename it back if `tx`
/// then fails to commit.
///
/// Authorization: renames a cluster-wide Postgres login. Callers MUST restrict this to superadmin
/// paths, and MUST hold [`lock_role_catalog`] on the same transaction.
pub async fn rename_instance_role(
/// paths, and MUST hold [`lock_role_catalog`] on `tx`.
pub async fn rename_datatable_role(
db: &DB,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
cluster: DatatableRoleCluster,
from: &str,
to: &str,
password: &str,
) -> Result<()> {
crate::datatable_roles_oss::rename_instance_role(tx, from, to, password).await
crate::datatable_roles_oss::rename_datatable_role(db, tx, cluster, from, to, password).await
}
/// A role owning anything in any database blocks its own `DROP ROLE`, and both its objects and the
@@ -302,8 +381,9 @@ pub async fn rename_instance_role(
/// registry. An unreachable database aborts the whole delete: dropping the role while one database
/// still holds objects owned by it leaves those objects owned by a numeric OID nobody can name.
///
/// Each pass runs as the instance's own Postgres user rather than `custom_instance_user`, which
/// owns the databases and can therefore revoke a grant whoever made it. `custom_instance_user`
/// Each pass runs as the cluster's administrator rather than `custom_instance_user`: on Windmill's
/// own cluster the instance's Postgres user, on the external one its configured admin login. Both
/// own the databases and can therefore revoke a grant whoever made it. `custom_instance_user`
/// could only undo what it granted itself, so a privilege planted by an operator in psql — the
/// ordinary way privileges reach a role — would survive and block the drop.
///
@@ -311,16 +391,19 @@ pub async fn rename_instance_role(
/// MUST restrict this to superadmin paths, and MUST hold [`lock_role_catalog`] on `tx`.
///
/// The per-database passes open their own connections and cannot join `tx`; the lock is what keeps
/// a concurrent mutation out while they run. Only the final `DROP ROLE` is on `tx`, so it commits
/// or rolls back with the catalog write that forgets the role. Those passes commit as they go, so
/// callers MUST have disabled the role in an earlier committed transaction: a failure part-way
/// then leaves a disabled role to retry, not an enabled one already stripped in some databases.
pub async fn drop_instance_role(
/// a concurrent mutation out while they run. On Windmill's own cluster only the final `DROP ROLE`
/// is on `tx`, so it commits or rolls back with the catalog write that forgets the role; on the
/// external cluster it runs there, and tolerates a role already gone so a retry after a failed
/// commit can finish. The passes commit as they go, so callers MUST have disabled the role in an
/// earlier committed transaction: a failure part-way then leaves a disabled role to retry, not an
/// enabled one already stripped in some databases.
pub async fn drop_datatable_role(
db: &DB,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
cluster: DatatableRoleCluster,
name: &str,
) -> Result<()> {
crate::datatable_roles_oss::drop_instance_role(db, tx, name).await
crate::datatable_roles_oss::drop_datatable_role(db, tx, cluster, name).await
}
#[cfg(test)]
@@ -24,12 +24,12 @@ pub fn datatable_roles_unavailable() -> Error {
#[cfg(all(feature = "private", feature = "enterprise"))]
pub(crate) use crate::datatable_roles_ee::{
can_use_datatable_role, can_use_datatable_role_in_governing_workspace, converge_connect_grants,
converge_connect_grants_with, create_instance_role, delete_role_catalog_entry,
drop_instance_role, ensure_can_use_datatable_role, ensure_datatable_admin_access,
converge_connect_grants_with, create_datatable_role, delete_role_catalog_entry,
drop_datatable_role, ensure_can_use_datatable_role, ensure_datatable_admin_access,
ensure_instance_db_grant_options_unchecked, forget_datatable_role_everywhere,
insert_role_catalog_entry, read_role_catalog, read_role_catalog_tx,
registered_instance_databases, rename_instance_role, resolve_datatable_role_connection,
set_instance_role_login, update_role_catalog_entry,
registered_instance_databases, rename_datatable_role, resolve_datatable_role_connection,
role_cluster, set_datatable_role_login, update_role_catalog_entry,
};
#[cfg(not(all(feature = "private", feature = "enterprise")))]
@@ -39,7 +39,7 @@ pub(crate) use ce::*;
mod ce {
use super::datatable_roles_unavailable as unavailable;
use crate::{
datatable_roles::{DatatableRoleCatalog, InstanceDatatableRole},
datatable_roles::{DatatableRoleCatalog, DatatableRoleCluster, InstanceDatatableRole},
db::AuthedRef,
error::Result,
workspaces::{
@@ -50,17 +50,31 @@ mod ce {
type Tx<'a> = sqlx::Transaction<'a, sqlx::Postgres>;
pub(crate) async fn read_role_catalog(_db: &DB) -> Result<DatatableRoleCatalog> {
pub(crate) async fn read_role_catalog(
_db: &DB,
_cluster: DatatableRoleCluster,
) -> Result<DatatableRoleCatalog> {
Err(unavailable())
}
pub(crate) async fn read_role_catalog_tx(_tx: &mut Tx<'_>) -> Result<DatatableRoleCatalog> {
pub(crate) async fn read_role_catalog_tx(
_tx: &mut Tx<'_>,
_cluster: DatatableRoleCluster,
) -> Result<DatatableRoleCatalog> {
Err(unavailable())
}
pub(crate) async fn role_cluster(
_tx: &mut Tx<'_>,
_id: &str,
) -> Result<Option<DatatableRoleCluster>> {
Err(unavailable())
}
pub(crate) async fn insert_role_catalog_entry(
_tx: &mut Tx<'_>,
_id: &str,
_cluster: DatatableRoleCluster,
_role: &InstanceDatatableRole,
) -> Result<()> {
Err(unavailable())
@@ -78,43 +92,57 @@ mod ce {
Err(unavailable())
}
pub(crate) async fn registered_instance_databases(_db: &DB) -> Result<Vec<String>> {
pub(crate) async fn registered_instance_databases(
_db: &DB,
_cluster: DatatableRoleCluster,
) -> Result<Vec<String>> {
Err(unavailable())
}
/// Nothing to converge: with no roles to admit, an instance database keeps the `CONNECT`
/// grants it was created with, `PUBLIC`'s included, as it did before roles existed.
pub(crate) async fn converge_connect_grants(_db: &DB, _dbname: &str) -> Result<()> {
/// Nothing to converge: with no roles to admit, a managed database keeps the `CONNECT` grants
/// it was created with, as it did before roles existed.
pub(crate) async fn converge_connect_grants(
_db: &DB,
_cluster: DatatableRoleCluster,
_dbname: &str,
) -> Result<()> {
Ok(())
}
/// As [`converge_connect_grants`].
pub(crate) async fn converge_connect_grants_with(
_db: &DB,
_cluster: DatatableRoleCluster,
_dbname: &str,
_catalog: &DatatableRoleCatalog,
) -> Result<()> {
Ok(())
}
pub(crate) async fn create_instance_role(
pub(crate) async fn create_datatable_role(
_db: &DB,
_tx: &mut Tx<'_>,
_cluster: DatatableRoleCluster,
_name: &str,
_password: &str,
) -> Result<()> {
Err(unavailable())
}
pub(crate) async fn set_instance_role_login(
pub(crate) async fn set_datatable_role_login(
_db: &DB,
_tx: &mut Tx<'_>,
_cluster: DatatableRoleCluster,
_name: &str,
_enabled: bool,
) -> Result<()> {
Err(unavailable())
}
pub(crate) async fn rename_instance_role(
pub(crate) async fn rename_datatable_role(
_db: &DB,
_tx: &mut Tx<'_>,
_cluster: DatatableRoleCluster,
_from: &str,
_to: &str,
_password: &str,
@@ -122,12 +150,18 @@ mod ce {
Err(unavailable())
}
pub(crate) async fn drop_instance_role(_db: &DB, _tx: &mut Tx<'_>, _name: &str) -> Result<()> {
pub(crate) async fn drop_datatable_role(
_db: &DB,
_tx: &mut Tx<'_>,
_cluster: DatatableRoleCluster,
_name: &str,
) -> Result<()> {
Err(unavailable())
}
pub(crate) async fn ensure_instance_db_grant_options_unchecked(
_db: &DB,
_cluster: DatatableRoleCluster,
_dbname: &str,
) -> Result<()> {
Err(unavailable())
@@ -148,7 +148,10 @@ pub async fn external_instance_databases(db: &DB) -> Result<BTreeMap<String, Cus
Ok(read_external_instance_pg_state(db).await?.databases)
}
/// The workspaces whose data tables name each database on the external cluster.
/// The workspaces whose data tables or Ducklake catalogs name each database on the external cluster,
/// and the forks whose Ducklake metadata schemas there are still waiting to be dropped: those rows
/// outlive a settings change, and cleanup cannot drop a schema in a database that is gone. A row
/// whose schema is already dropped only waits on object storage, which needs no database.
///
/// Authorization: reads every workspace's settings and checks nothing. Callers MUST be superadmin
/// or an internal lifecycle path.
@@ -164,7 +167,21 @@ pub async fn external_instance_database_usages<'c>(
ELSE '{}'::jsonb END
) AS dt(k, entry)
WHERE entry->'database'->>'resource_type' = 'external_instance'
AND entry->'database'->>'resource_path' IS NOT NULL",
AND entry->'database'->>'resource_path' IS NOT NULL
UNION ALL
SELECT ws.workspace_id, entry->'catalog'->>'resource_path'
FROM workspace_settings ws
CROSS JOIN LATERAL jsonb_each(
CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object'
THEN ws.ducklake->'ducklakes'
ELSE '{}'::jsonb END
) AS dl(k, entry)
WHERE entry->'catalog'->>'resource_type' = 'external_instance'
AND entry->'catalog'->>'resource_path' IS NOT NULL
UNION ALL
SELECT workspace_id, substring(catalog FROM length('external_instance:') + 1)
FROM fork_ducklake_namespace
WHERE catalog LIKE 'external\\_instance:%' AND NOT schema_dropped",
)
.fetch_all(db)
.await?;
@@ -175,27 +192,48 @@ pub async fn external_instance_database_usages<'c>(
Ok(usages)
}
/// Refuse to unset the cluster while Windmill still has databases on it, or a workspace still
/// points at one: every data table there would stop resolving. Allowed on every edition, so a
/// Refuse to unset the cluster while Windmill still has databases or data table roles on it, or a
/// workspace still points at one: every data table there would stop resolving, and every role
/// would be a login nothing can drop any more. Allowed on every edition, so a
/// downgraded instance can still clear a setting it no longer uses.
pub async fn ensure_external_instance_pg_removable(db: &DB) -> Result<()> {
ensure_external_instance_pg_unused(db, &format!("removing {EXTERNAL_INSTANCE_PG_SETTING}"))
.await
}
/// Refuse while Windmill has databases or data table roles on the cluster, or a workspace points
/// at one of its databases. `before` finishes the sentence saying what to do first.
async fn ensure_external_instance_pg_unused(db: &DB, before: &str) -> Result<()> {
let state = read_external_instance_pg_state(db).await?;
let usages = external_instance_database_usages(db).await?;
if state.databases.is_empty() && usages.is_empty() {
let roles = sqlx::query_scalar::<_, String>(
"SELECT name FROM datatable_role WHERE cluster = 'external_instance' ORDER BY name",
)
.fetch_all(db)
.await?;
if state.databases.is_empty() && usages.is_empty() && roles.is_empty() {
return Ok(());
}
let names = state
.databases
.keys()
.chain(usages.keys())
.collect::<BTreeSet<_>>()
.into_iter()
.cloned()
.collect::<Vec<_>>()
.join(", ");
let mut held = vec![];
if !(state.databases.is_empty() && usages.is_empty()) {
let names = state
.databases
.keys()
.chain(usages.keys())
.collect::<BTreeSet<_>>()
.into_iter()
.cloned()
.collect::<Vec<_>>()
.join(", ");
held.push(format!("databases in use ({names})"));
}
if !roles.is_empty() {
held.push(format!("data table roles ({})", roles.join(", ")));
}
Err(Error::BadRequest(format!(
"The external instance cluster still holds databases in use ({names}). Drop them and \
repoint the data tables using them before removing {EXTERNAL_INSTANCE_PG_SETTING}."
"The external instance cluster still holds {}. Drop them and repoint the data tables and \
Ducklake catalogs using them before {before}.",
held.join(" and ")
)))
}
@@ -361,9 +399,10 @@ pub async fn write_external_instance_pg_from_diff(
Ok(())
}
/// Refuse pointing the setting at another host or port while databases live on the current one.
/// Data tables name databases, not clusters, so they would silently resolve to whatever the new
/// cluster holds under the same names. Other fields (admin login, sslmode) may change freely.
/// Refuse pointing the setting at another host or port while databases or data table roles live on
/// the current one. Data tables name databases, and the role catalog names logins, not clusters, so
/// both would silently resolve to whatever the new cluster holds under the same names. Other fields
/// (admin login, sslmode) may change freely.
async fn ensure_external_instance_pg_not_repointed(
db: &DB,
value: &serde_json::Value,
@@ -377,17 +416,11 @@ async fn ensure_external_instance_pg_not_repointed(
if external_instance_pg_address(&current) == external_instance_pg_address(&desired) {
return Ok(());
}
let state = read_external_instance_pg_state(db).await?;
let usages = external_instance_database_usages(db).await?;
if state.databases.is_empty() && usages.is_empty() {
return Ok(());
}
Err(Error::BadRequest(format!(
"The external instance cluster at {}:{} still holds databases in use. Drop them and repoint \
what uses them before pointing {EXTERNAL_INSTANCE_PG_SETTING} at another cluster.",
current.host.trim(),
current.port.unwrap_or(5432)
)))
ensure_external_instance_pg_unused(
db,
&format!("pointing {EXTERNAL_INSTANCE_PG_SETTING} at another cluster"),
)
.await
}
/// Converge the external cluster on the configured login: check what it can do, create or update
+32 -6
View File
@@ -1563,18 +1563,20 @@ pub(crate) fn instance_db_grants(dbname: &str) -> String {
)
}
/// Re-apply [`instance_db_grants`] to an instance database provisioned before data table roles
/// existed, whose grants carry no grant option. Connects as the instance's own Postgres user —
/// the database and `public` schema owner — since only it can hand out an option it holds.
/// Re-apply [`instance_db_grants`] to a managed database provisioned before data table roles
/// existed, whose grants carry no grant option. Connects as the cluster's administrator — the
/// database and `public` schema owner — since only it can hand out an option it holds.
///
/// Authorization: reaches an instance database with the server's own credentials and checks
/// Authorization: reaches a managed database with the server's own credentials and checks
/// nothing. Callers MUST have authorized administration of `dbname` — superadmin, or an admin of
/// the workspace governing a data table on it.
pub async fn ensure_instance_db_grant_options_unchecked(
db: &DB,
cluster: crate::datatable_roles::DatatableRoleCluster,
dbname: &str,
) -> error::Result<()> {
crate::datatable_roles_oss::ensure_instance_db_grant_options_unchecked(db, dbname).await
crate::datatable_roles_oss::ensure_instance_db_grant_options_unchecked(db, cluster, dbname)
.await
}
/// Create a custom instance database: CREATE DATABASE, grant permissions, register in global_settings.
@@ -1651,7 +1653,13 @@ pub async fn create_custom_instance_database(
// A data table role can only reach a database it may CONNECT to, and PUBLIC's default CONNECT
// would otherwise let every role in regardless of what this instance defines. Best-effort: a
// failure here leaves the database usable as `admin`, and the next role change repairs it.
if let Err(e) = crate::datatable_roles::converge_connect_grants(db, dbname).await {
if let Err(e) = crate::datatable_roles::converge_connect_grants(
db,
crate::datatable_roles::DatatableRoleCluster::Instance,
dbname,
)
.await
{
tracing::warn!("Could not set CONNECT grants on instance database '{dbname}': {e}");
}
@@ -1659,6 +1667,24 @@ pub async fn create_custom_instance_database(
Ok(())
}
/// The system's CA bundle file, for libpq clients that cannot take `sslrootcert=system`: that value
/// needs libpq 16, and verify-full only.
pub fn system_ca_bundle() -> Option<std::path::PathBuf> {
std::env::var_os("SSL_CERT_FILE")
.map(std::path::PathBuf::from)
.into_iter()
.chain(
[
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/cert.pem",
"/etc/ssl/ca-bundle.pem",
]
.map(std::path::PathBuf::from),
)
.find(|path| path.is_file())
}
/// Refuse a workspace member writing a fork copy into, or pointing a fork at, the managed database
/// `dbname` of `kind`, unless `w_id` created it for that ([`create_custom_instance_database`], or
/// its external instance counterpart) and nothing uses it yet. The `wm_fork_` prefix is no
+29 -6
View File
@@ -1551,13 +1551,14 @@ pub async fn lock_fork_datatables(conn: &mut sqlx::PgConnection, w_id: &str) ->
}
impl GoverningDatatable {
/// Backed by the Windmill instance's own Postgres, which is the only substrate data table
/// roles apply to.
pub fn is_instance(&self) -> bool {
/// The Windmill-managed cluster whose data table roles this entry can use. `None` for a
/// resource-backed one: roles are logins Windmill creates, and it creates none on a host a
/// workspace admin chose.
pub fn role_cluster(&self) -> Option<crate::datatable_roles::DatatableRoleCluster> {
self.datatable
.database
.as_ref()
.is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance)
.and_then(|d| crate::datatable_roles::DatatableRoleCluster::of(d.resource_type))
}
}
@@ -2257,6 +2258,10 @@ pub enum DucklakeCatalogResourceType {
Postgresql,
Mysql,
Instance,
/// On the external instance cluster ([`crate::external_instance_pg`]). Enterprise Edition.
#[serde(rename = "external_instance")]
#[strum(serialize = "external_instance")]
ExternalInstance,
}
#[derive(Deserialize, Serialize)]
@@ -2784,7 +2789,16 @@ async fn ducklake_conn_data(
let ducklake = serde_json::from_value::<Ducklake>(ducklake)?;
let catalog_resource =
if ducklake.catalog.resource_type == DucklakeCatalogResourceType::Instance {
if ducklake.catalog.resource_type == DucklakeCatalogResourceType::ExternalInstance {
let pg_creds = crate::external_instance_pg::external_instance_connection_unchecked(
db,
&ducklake.catalog.resource_path,
false,
)
.await?;
serde_json::to_value(&pg_creds)
.map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))?
} else if ducklake.catalog.resource_type == DucklakeCatalogResourceType::Instance {
let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
pg_creds.dbname = ducklake.catalog.resource_path.clone();
pg_creds.user = Some("custom_instance_user".to_string());
@@ -3165,6 +3179,14 @@ async fn register_fork_ducklake_namespace(
{
return Ok(());
}
let mut tx = db.begin().await?;
// A row naming an external database counts as a use of it. Written under the lock a drop takes,
// and only while the database is still registered, so a drop cannot slip in between the
// settings this attach resolved and the row that protects the database.
if let Some(dbname) = catalog.strip_prefix("external_instance:") {
crate::external_instance_pg::ensure_external_instance_database_registered(&mut tx, dbname)
.await?;
}
sqlx::query!(
"INSERT INTO fork_ducklake_namespace
(workspace_id, ducklake_name, metadata_schema, catalog, storage, storage_ref, data_path)
@@ -3179,9 +3201,10 @@ async fn register_fork_ducklake_namespace(
&storage_ref,
data_path,
)
.execute(db)
.execute(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("registering fork ducklake namespace: {e:#}")))?;
tx.commit().await?;
let mut locations = FORK_DUCKLAKE_REGISTERED
.get(w_id)
.filter(|(_, exp)| *exp > now)
+149 -7
View File
@@ -2240,11 +2240,99 @@ fn parse_attach_db_resource<'a>(query: &'a str) -> Option<ParsedAttachDbResource
None
}
/// The verification a DuckDB postgres attach keeps, as its libpq `sslmode` and `sslrootcert`.
///
/// Attaches have always turned verify-ca and verify-full into `require`, which resources rely on.
/// A connection that explicitly refuses invalid certificates — the external instance cluster's —
/// keeps its mode instead: under `require` its shared password would go to whichever server
/// answers. DuckDB's libpq takes one root file, so it gets the system bundle plus the configured
/// certificate.
fn pg_attach_verification(res: &PgDatabase) -> Result<Option<(&str, std::path::PathBuf)>> {
let mode = match res.sslmode.as_deref() {
Some(mode @ ("verify-ca" | "verify-full")) if res.accept_invalid_certs == Some(false) => mode,
_ => return Ok(None),
};
let bundle = windmill_common::system_ca_bundle()
.map(std::fs::read_to_string)
.transpose()
.map_err(|e| Error::ExecutionErr(format!("Failed to read the system CA bundle: {e}")))?
.unwrap_or_default();
let pem = res.root_certificate_pem.as_deref().unwrap_or_default();
if bundle.is_empty() && pem.is_empty() {
return Err(Error::ExecutionErr(format!(
"sslmode {mode} needs a root certificate, and this worker has no system CA bundle"
)));
}
let roots = format!("{bundle}\n{pem}\n");
use sha2::Digest;
let dir = std::env::temp_dir().join("windmill-pg-roots");
let path = dir.join(format!(
"{}.pem",
hex::encode(&sha2::Sha256::digest(roots.as_bytes())[..8])
));
let write_err = |e: std::io::Error| {
Error::ExecutionErr(format!("Failed to write root certificates: {e}"))
};
if path.is_file() {
// Marks it recently used, so pruning takes the others first.
let _ = std::fs::File::options()
.append(true)
.open(&path)
.and_then(|f| f.set_modified(std::time::SystemTime::now()));
} else {
std::fs::create_dir_all(&dir).map_err(write_err)?;
// Renamed into place: a job attaching concurrently must never read a half-written file.
let partial = path.with_extension(format!("{}.partial", Uuid::new_v4()));
std::fs::write(&partial, &roots)
.and_then(|()| std::fs::rename(&partial, &path))
.map_err(write_err)?;
prune_pg_roots(&dir, &path);
}
Ok(Some((mode, path)))
}
/// Root files outlive the job: a resource's certificate is workspace-controlled, so each distinct
/// one would otherwise add a file forever. Keeps the most recently used ones.
const PG_ROOTS_KEPT: usize = 32;
fn prune_pg_roots(dir: &std::path::Path, keep: &std::path::Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "pem") && p != keep)
.filter_map(|p| Some((std::fs::metadata(&p).ok()?.modified().ok()?, p)))
.collect();
if files.len() < PG_ROOTS_KEPT {
return;
}
files.sort();
for (_, p) in &files[..=files.len() - PG_ROOTS_KEPT] {
let _ = std::fs::remove_file(p);
}
}
fn pg_attach_uri(res: &PgDatabase) -> Result<String> {
let uri = res.to_uri();
let Some((mode, roots)) = pg_attach_verification(res)? else {
return Ok(uri);
};
let base = uri.strip_suffix("?sslmode=require").ok_or_else(|| {
Error::internal_err("unexpected sslmode in a postgres connection URI".to_string())
})?;
Ok(format!(
"{base}?sslmode={mode}&sslrootcert={}",
urlencoding::encode(&roots.to_string_lossy())
))
}
fn format_attach_db_conn_str(db_resource: Value, db_type: &str) -> Result<String> {
let s = match db_type.to_lowercase().as_str() {
"postgres" | "postgresql" => {
let res: PgDatabase = serde_json::from_value(db_resource)?;
res.to_uri()
pg_attach_uri(&res)?
}
#[cfg(feature = "mysql")]
"mysql" => {
@@ -2391,7 +2479,9 @@ async fn transform_attach_ducklake(
format!(", {}", user_extra_args)
};
let db_type = match ducklake.catalog.resource_type {
DucklakeCatalogResourceType::Instance => "postgres",
DucklakeCatalogResourceType::Instance | DucklakeCatalogResourceType::ExternalInstance => {
"postgres"
}
_ => ducklake.catalog.resource_type.as_ref(),
};
@@ -2507,7 +2597,8 @@ fn fork_defer_statements(
hidden_passwords.lock().unwrap().push(pwd.to_string());
}
let db_type = match a.catalog.resource_type {
DucklakeCatalogResourceType::Instance => "postgres",
DucklakeCatalogResourceType::Instance
| DucklakeCatalogResourceType::ExternalInstance => "postgres",
_ => a.catalog.resource_type.as_ref(),
};
stmts.push(get_attach_db_install_str(db_type)?.to_string());
@@ -2700,10 +2791,21 @@ fn pg_secret_attach_statements(db_resource: Value, alias_name: &str) -> Result<V
let esc = |s: &str| s.replace('\'', "''");
// The postgres secret type has no sslmode parameter, so it goes in the ATTACH
// string; only the libpq values PgDatabase::to_uri collapses to are forwarded.
let sslmode = match res.sslmode.as_deref() {
Some("disable") => "disable",
Some("require") | Some("verify-ca") | Some("verify-full") => "require",
_ => "prefer",
let sslmode = match pg_attach_verification(&res)? {
// A libpq keyword/value string: the path is quoted for libpq, then for the DuckDB literal.
Some((mode, roots)) => format!(
"{mode} sslrootcert=''{}''",
roots
.to_string_lossy()
.replace('\\', "\\\\")
.replace('\'', "\\''")
),
None => match res.sslmode.as_deref() {
Some("disable") => "disable",
Some("require") | Some("verify-ca") | Some("verify-full") => "require",
_ => "prefer",
}
.to_string(),
};
let secret_name = datatable_secret_name(alias_name);
Ok(vec![
@@ -2796,6 +2898,46 @@ pub struct Arg {
mod tests {
use super::*;
#[test]
fn pg_attach_keeps_verification_only_when_required() {
let pg = |sslmode: &str, accept_invalid_certs: Option<bool>| PgDatabase {
host: "db.internal".to_string(),
user: Some("custom_instance_user".to_string()),
password: Some("pw".to_string()),
port: None,
sslmode: Some(sslmode.to_string()),
dbname: "dt".to_string(),
root_certificate_pem: Some("-----BEGIN CERTIFICATE-----test".to_string()),
accept_invalid_certs,
use_iam_auth: None,
region: None,
};
let uri = pg_attach_uri(&pg("verify-full", Some(false))).unwrap();
assert!(uri.contains("?sslmode=verify-full&sslrootcert="), "{uri}");
let root = urlencoding::decode(uri.split("sslrootcert=").nth(1).unwrap()).unwrap();
let roots = std::fs::read_to_string(root.as_ref()).unwrap();
for i in 0..(PG_ROOTS_KEPT + 5) {
let mut other = pg("verify-full", Some(false));
other.root_certificate_pem = Some(format!("-----BEGIN CERTIFICATE-----{i}"));
pg_attach_uri(&other).unwrap();
}
let kept = std::fs::read_dir(std::env::temp_dir().join("windmill-pg-roots"))
.unwrap()
.filter(|e| e.as_ref().unwrap().path().extension().is_some_and(|x| x == "pem"))
.count();
assert!(kept <= PG_ROOTS_KEPT, "{kept} root files kept");
assert!(roots.contains("-----BEGIN CERTIFICATE-----test"));
let external = serde_json::to_value(pg("verify-full", Some(false))).unwrap();
let attach = &pg_secret_attach_statements(external, "dt").unwrap()[3];
assert!(
attach.starts_with(&format!("ATTACH 'sslmode=verify-full sslrootcert=''{}''", root)),
"{attach}"
);
// A resource that never opted in keeps the historical downgrade.
assert!(pg_attach_uri(&pg("verify-full", None)).unwrap().ends_with("?sslmode=require"));
assert!(pg_attach_uri(&pg("require", Some(false))).unwrap().ends_with("?sslmode=require"));
}
#[test]
fn attach_datatable_parses_name_and_role() {
let reference_of = |q: &str| parse_attach_datatable(q).unwrap().reference;
+4 -2
View File
@@ -111,6 +111,8 @@ const migrateCommand = new Command()
)
.action(migrateDown as any);
type DataTableResourceType = "postgresql" | "instance" | "external_instance";
async function create(
opts: GlobalOptions & { resource?: string; force?: boolean },
name?: string,
@@ -139,12 +141,12 @@ async function create(
const datatables: Record<
string,
{ database: { resource_type: "postgresql" | "instance"; resource_path?: string } }
{ database: { resource_type: DataTableResourceType; resource_path?: string } }
> = {};
for (const d of existing) {
datatables[d.name] = {
database: {
resource_type: d.resource_type as "postgresql" | "instance",
resource_type: d.resource_type as DataTableResourceType,
resource_path: d.resource_path ?? undefined,
},
};
@@ -27,6 +27,7 @@
import WebhookBaseUrlSetting from './instanceSettings/WebhookBaseUrlSetting.svelte'
import WsConnectivityTest from './instanceSettings/WsConnectivityTest.svelte'
import InstanceBannerSetting from './instanceSettings/InstanceBannerSetting.svelte'
import ExternalInstancePgSettings from './instanceSettings/ExternalInstancePgSettings.svelte'
import IndexerMemorySettings from './instanceSettings/IndexerMemorySettings.svelte'
import IndexerJobIndexSettings from './instanceSettings/IndexerJobIndexSettings.svelte'
import IndexerLogIndexSettings from './instanceSettings/IndexerLogIndexSettings.svelte'
@@ -781,10 +782,10 @@
/>
<p class="text-xs text-tertiary">
Comma-separated host/IP patterns the proxy still traces but for which it skips
upstream TLS certificate verification. Use for internal endpoints with
self-signed or otherwise untrusted certificates — unlike NO_PROXY above, these
requests stay traced. Same matching as NO_PROXY (<code>example.com</code> matches
subdomains; <code>.example.com</code> matches subdomains only).
upstream TLS certificate verification. Use for internal endpoints with self-signed
or otherwise untrusted certificates — unlike NO_PROXY above, these requests stay
traced. Same matching as NO_PROXY (<code>example.com</code> matches subdomains;
<code>.example.com</code> matches subdomains only).
</p>
</div>
<div class="flex flex-col gap-1">
@@ -872,6 +873,8 @@
<WsConnectivityTest {values} />
{:else if setting.fieldType == 'instance_banner'}
<InstanceBannerSetting {values} disabled={loading} />
{:else if setting.fieldType == 'external_instance_pg'}
<ExternalInstancePgSettings {values} disabled={loading} />
{/if}
{#if hasError}
<span class="text-red-600 dark:text-red-400 text-xs">
@@ -730,6 +730,7 @@
secret_backend: ['token'],
object_store_cache_config: ['secret_key', 'serviceAccountKey'],
custom_instance_pg_databases: ['user_pwd'],
external_instance_pg: ['password'],
rsa_keys: ['private_key'],
github_enterprise_app: ['private_key']
}
@@ -1234,6 +1235,11 @@
description="Configure a self-managed GitHub App for git sync on GitHub.com, GHE Cloud or GitHub Enterprise Server."
link="https://www.windmill.dev/docs/integrations/git_repository#self-managed-github-app"
/>
{:else if category == 'External Postgres'}
<SettingsPageHeader
title="External Postgres"
description="Store data tables and Ducklake catalogs on a PostgreSQL cluster outside Windmill's own database. Save the connection, set the cluster up, then create databases for workspaces to use."
/>
{:else if category == 'DB Health'}
<SettingsPageHeader
title="DB Health"
@@ -49,7 +49,11 @@
})
.catch((error) => {
console.error('Save failed:', error)
sendUserToast(error?.message ?? 'Save failed', true)
// An ApiError's message is only the HTTP status text; the server's reason is its body.
sendUserToast(
(typeof error?.body === 'string' && error.body) || error?.message || 'Save failed',
true
)
saveStatus = 'error'
statusTimeout = setTimeout(() => {
saveStatus = null
@@ -71,6 +71,7 @@ export interface Setting {
| 'ws_connectivity'
| 'retention_overrides'
| 'instance_banner'
| 'external_instance_pg'
storage: SettingStorage
advancedToggle?: {
label: string
@@ -718,6 +719,17 @@ export const settings: Record<string, Setting[]> = {
}
],
'DB Health': [],
'External Postgres': [
{
label: 'External instance cluster',
description:
'A PostgreSQL cluster Windmill administers for data tables and Ducklake catalogs of the External instance type. The admin user needs CREATEDB and CREATEROLE, plus REPLICATION for Postgres triggers. Windmill creates its own roles and databases there and leaves everything else on the cluster alone.',
key: 'external_instance_pg',
fieldType: 'external_instance_pg',
storage: 'setting',
ee_only: 'External instance databases are an Enterprise Edition feature'
}
],
Registries: [
{
label: 'Instance Python Version',
@@ -1241,6 +1253,14 @@ export const instanceSettingsNavigationGroups = [
aiId: 'instance-settings-object-storage',
aiDescription: 'Instance object storage settings',
isEE: true
},
{
id: 'external_postgres',
label: 'External Postgres',
aiId: 'instance-settings-external-postgres',
aiDescription:
'External PostgreSQL cluster Windmill manages for external instance data tables and Ducklake catalogs',
isEE: true
}
]
},
@@ -1365,6 +1385,7 @@ export const tabToCategoryMap: Record<string, string> = {
github_enterprise_app: 'GitHub App',
websocket: 'WebSocket',
db_health: 'DB Health',
external_postgres: 'External Postgres',
lsp: 'LSP'
}
@@ -1401,6 +1422,7 @@ export const categoryToTabMap: Record<string, string> = {
'GitHub App': 'github_enterprise_app',
WebSocket: 'websocket',
'DB Health': 'db_health',
'External Postgres': 'external_postgres',
LSP: 'lsp'
}
@@ -0,0 +1,400 @@
<script lang="ts">
import { Alert, Button } from '$lib/components/common'
import TextInput from '../text_input/TextInput.svelte'
import Password from '../Password.svelte'
import Select from '../select/Select.svelte'
import DataTable from '../table/DataTable.svelte'
import Head from '../table/Head.svelte'
import Row from '../table/Row.svelte'
import Cell from '../table/Cell.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import {
SettingService,
type CustomInstanceDbTag,
type ExternalInstancePgSetupReport
} from '$lib/gen'
import { instanceSettingsSaved } from '../instanceSettings'
import { enterpriseLicense } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { resource } from 'runed'
import { deepEqual } from 'fast-equals'
import {
CircleCheck,
CircleX,
KeyRound,
Plus,
Trash2,
TriangleAlert,
Wrench
} from 'lucide-svelte'
import type { Writable } from 'svelte/store'
interface Props {
values: Writable<Record<string, any>>
disabled?: boolean
}
let { values, disabled = false }: Props = $props()
const KEY = 'external_instance_pg'
const SSLMODES = ['verify-full', 'verify-ca', 'require', 'prefer', 'disable']
let isDisabled = $derived(disabled || !$enterpriseLicense)
function field(key: string): any {
return $values[KEY]?.[key]
}
// Empty inputs are removed rather than sent: the backend reads every field as optional, and an
// empty string would be a real (and invalid) host, port or sslmode.
function setField(key: string, value: any) {
const next = { ...($values[KEY] ?? {}) }
if (value === '' || value === undefined || value === null) {
delete next[key]
} else {
next[key] = key === 'port' ? Number(value) : value
}
$values[KEY] = Object.keys(next).length > 0 ? next : undefined
}
// Setup reads the saved settings, never the form, so it waits until the form is saved.
const saved = resource(
() => $instanceSettingsSaved,
async () => {
try {
return (await SettingService.getGlobal({ key: KEY })) ?? undefined
} catch {
return undefined
}
}
)
let unsaved = $derived(!saved.loading && !deepEqual(saved.current ?? undefined, $values[KEY]))
let refreshKey = $state(0)
const status = resource(
() => [$instanceSettingsSaved, refreshKey],
async () => {
try {
return await SettingService.getExternalInstancePgStatus()
} catch {
return undefined
}
}
)
const databases = resource(
() => [$instanceSettingsSaved, refreshKey],
async () => {
try {
return await SettingService.listExternalInstancePgDatabases()
} catch {
return {}
}
}
)
let freshReport: ExternalInstancePgSetupReport | undefined = $state(undefined)
let report = $derived(freshReport ?? status.current?.last_setup)
let runningSetup: 'setup' | 'rotate' | undefined = $state(undefined)
const confirmationModal = createAsyncConfirmationModal()
async function runSetup(rotate: boolean) {
if (rotate) {
const ok = await confirmationModal.ask({
title: 'Rotate passwords',
children:
'Windmill generates new passwords for its two roles on the external cluster. Jobs that connect afterwards use the new ones.',
confirmationText: 'Rotate'
})
if (!ok) return
}
runningSetup = rotate ? 'rotate' : 'setup'
try {
freshReport = await SettingService.setupExternalInstancePg({
requestBody: { rotate_passwords: rotate }
})
sendUserToast(
freshReport.success ? 'External cluster is set up' : 'Setup failed, see the report below',
!freshReport.success
)
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
} finally {
runningSetup = undefined
refreshKey++
}
}
let newDbName = $state('')
let newDbTag: CustomInstanceDbTag = $state('datatable')
let creating = $state(false)
async function createDatabase() {
creating = true
try {
await SettingService.createExternalInstancePgDatabase({
name: newDbName.trim(),
requestBody: { tag: newDbTag }
})
sendUserToast(`Created database ${newDbName.trim()}`)
newDbName = ''
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
} finally {
creating = false
refreshKey++
}
}
async function dropDatabase(name: string) {
const ok = await confirmationModal.ask({
title: `Drop database ${name}`,
children:
'The database and everything in it is deleted from the external cluster. This cannot be undone.',
confirmationText: 'Drop database'
})
if (!ok) return
try {
await SettingService.dropExternalInstancePgDatabase({ name })
sendUserToast(`Dropped database ${name}`)
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
} finally {
refreshKey++
}
}
let databaseEntries = $derived(Object.entries(databases.current ?? {}))
let setUp = $derived(!!status.current?.last_setup?.success)
</script>
<div class="flex flex-col gap-6">
{#if !$enterpriseLicense}
<Alert
type="info"
title="External instance databases are an Enterprise Edition feature"
size="xs"
/>
{/if}
<div class="grid grid-cols-2 gap-x-2 gap-y-4">
<div class="flex flex-col gap-1">
<label for="external_pg_host" class="text-xs font-semibold text-emphasis">Host</label>
<TextInput
inputProps={{ id: 'external_pg_host', placeholder: 'db.example.com', disabled: isDisabled }}
bind:value={() => field('host'), (v) => setField('host', v)}
/>
</div>
<div class="flex flex-col gap-1">
<label for="external_pg_port" class="text-xs font-semibold text-emphasis">Port</label>
<TextInput
inputProps={{
id: 'external_pg_port',
type: 'number',
placeholder: '5432',
disabled: isDisabled
}}
bind:value={() => field('port'), (v) => setField('port', v)}
/>
</div>
<div class="flex flex-col gap-1">
<label for="external_pg_user" class="text-xs font-semibold text-emphasis">Admin user</label>
<TextInput
inputProps={{ id: 'external_pg_user', placeholder: 'windmill_admin', disabled: isDisabled }}
bind:value={() => field('user'), (v) => setField('user', v)}
/>
</div>
<div class="flex flex-col gap-1">
<label for="external_pg_password" class="text-xs font-semibold text-emphasis">Password</label>
<Password
id="external_pg_password"
small
disabled={isDisabled}
bind:password={() => field('password'), (v) => setField('password', v)}
/>
</div>
<div class="flex flex-col gap-1">
<label for="external_pg_dbname" class="text-xs font-semibold text-emphasis">
Maintenance database
</label>
<TextInput
inputProps={{ id: 'external_pg_dbname', placeholder: 'postgres', disabled: isDisabled }}
bind:value={() => field('dbname'), (v) => setField('dbname', v)}
/>
</div>
<div class="flex flex-col gap-1">
<label for="external_pg_sslmode" class="text-xs font-semibold text-emphasis">SSL mode</label>
<Select
id="external_pg_sslmode"
items={SSLMODES.map((m) => ({ value: m, label: m }))}
placeholder="verify-full (default)"
clearable
disabled={isDisabled}
bind:value={() => field('sslmode'), (v) => setField('sslmode', v)}
/>
</div>
<div class="col-span-2 flex flex-col gap-1">
<label for="external_pg_root_cert" class="text-xs font-semibold text-emphasis">
Root certificate (PEM)
</label>
<TextInput
underlyingInputEl="textarea"
inputProps={{
id: 'external_pg_root_cert',
placeholder: '-----BEGIN CERTIFICATE-----',
rows: 3,
disabled: isDisabled
}}
bind:value={() => field('root_certificate_pem'), (v) => setField('root_certificate_pem', v)}
/>
<span class="text-2xs text-secondary">
Leave empty to verify against the system trust store.
</span>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Wrench }}
disabled={isDisabled || unsaved || !saved.current || !!runningSetup}
loading={runningSetup === 'setup'}
onclick={() => runSetup(false)}
>
Set up cluster
</Button>
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: KeyRound }}
disabled={isDisabled || unsaved || !saved.current || !setUp || !!runningSetup}
loading={runningSetup === 'rotate'}
onclick={() => runSetup(true)}
>
Rotate passwords
</Button>
{#if unsaved}
<span class="text-xs text-secondary">Save the settings before setting the cluster up.</span>
{:else if !saved.current}
<span class="text-xs text-secondary">Fill in the connection and save to set it up.</span>
{/if}
</div>
{#if report}
<div class="flex flex-col gap-1 rounded-md border p-3 bg-surface-secondary">
<div class="flex items-center justify-between text-xs">
<span class="font-semibold text-emphasis">
{report.success ? 'Last setup succeeded' : 'Last setup failed'}
</span>
<span class="text-secondary">{new Date(report.finished_at).toLocaleString()}</span>
</div>
<ul class="flex flex-col gap-1.5 mt-1">
{#each report.steps as step, i (i)}
<li class="flex gap-2 text-xs">
{#if step.status === 'ok'}
<CircleCheck size={14} class="text-green-600 dark:text-green-400 shrink-0 mt-0.5" />
{:else if step.status === 'warning'}
<TriangleAlert
size={14}
class="text-yellow-600 dark:text-yellow-400 shrink-0 mt-0.5"
/>
{:else}
<CircleX size={14} class="text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
{/if}
<span class="font-mono text-secondary shrink-0">{step.name}</span>
<span class="text-primary break-words">{step.message}</span>
</li>
{/each}
</ul>
</div>
{/if}
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-xs font-semibold text-emphasis">Databases</span>
<span class="text-xs text-secondary">
Databases Windmill created on this cluster. Workspaces use them by picking the
<span class="font-semibold">External instance</span> type in their data table or Ducklake settings.
</span>
</div>
<DataTable>
<Head>
<tr>
<Cell head first>Name</Cell>
<Cell head>Used for</Cell>
<Cell head>Used by</Cell>
<Cell head last></Cell>
</tr>
</Head>
<tbody class="divide-y bg-surface-tertiary">
{#if databaseEntries.length === 0}
<Row>
<Cell colspan={4} class="text-center text-xs text-secondary py-4">No database yet</Cell>
</Row>
{/if}
{#each databaseEntries as [name, db] (name)}
<Row>
<Cell first class="font-mono text-xs">{name}</Cell>
<Cell class="text-xs">{db.tag === 'ducklake' ? 'Ducklake' : 'Data table'}</Cell>
<Cell class="text-xs">
{(db.used_by_workspaces ?? []).join(', ') || '—'}
</Cell>
<Cell last class="text-right">
<Button
unifiedSize="sm"
variant="subtle"
startIcon={{ icon: Trash2 }}
iconOnly
disabled={isDisabled || (db.used_by_workspaces ?? []).length > 0}
title={(db.used_by_workspaces ?? []).length > 0
? 'Still used by a workspace'
: `Drop ${name}`}
onclick={() => dropDatabase(name)}
/>
</Cell>
</Row>
{/each}
</tbody>
</DataTable>
<div class="flex items-center gap-2">
<TextInput
class="flex-1"
inputProps={{
id: 'external_pg_new_db',
placeholder: 'New database name',
disabled: isDisabled || !setUp
}}
bind:value={newDbName}
/>
<Select
id="external_pg_new_db_tag"
class="w-36"
items={[
{ value: 'datatable', label: 'Data table' },
{ value: 'ducklake', label: 'Ducklake' }
]}
disabled={isDisabled || !setUp}
bind:value={newDbTag}
/>
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: Plus }}
disabled={isDisabled || !setUp || !newDbName.trim() || creating}
loading={creating}
onclick={createDatabase}
>
Create database
</Button>
</div>
{#if !setUp}
<span class="text-xs text-secondary">Set the cluster up before creating databases.</span>
{/if}
</div>
</div>
<ConfirmationModal {...confirmationModal.props} />
@@ -78,6 +78,9 @@
const unusedRoles = $derived(availableRoles.filter((r) => !roles.some((row) => row.id === r.id)))
const pendingRoles = $derived(roles.filter((r) => r.id === undefined))
let instanceRoles: InstanceRolesButton | undefined = $state(undefined)
const clusterLabel = $derived(
info?.cluster === 'external_instance' ? 'the external instance' : 'this instance'
)
const roleKey = (role: EditedRole) => role.id ?? `pending:${role.name}`
@@ -174,7 +177,7 @@
async function refreshCatalog() {
let fresh: InstanceDatatableRole[]
try {
fresh = await SettingService.listInstanceDatatableRoles()
fresh = await SettingService.listInstanceDatatableRoles({ cluster: info?.cluster })
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
return
@@ -231,7 +234,7 @@
<DrawerContent
title="Roles — {datatable}"
on:close={() => (drawerOpen = false)}
tooltip="A data table role is a Postgres login. A job that names one connects as it, and Postgres decides what it may touch — grant it privileges under Access. Roles are defined for the whole instance; here you say who may use each one on this data table."
tooltip="A data table role is a Postgres login. A job that names one connects as it, and Postgres decides what it may touch — grant it privileges under Access. Roles are defined once per cluster, for every data table on it; here you say who may use each one on this data table."
>
{#snippet titleExtra()}
<Badge color="blue" small>Beta</Badge>
@@ -254,9 +257,9 @@
{#if !info?.supported}
<Alert type="info" title="Not available on this data table" size="xs">
A data table role is a Postgres login on the Windmill instance's own database, so only a
data table backed by that database can use one. This one is backed by a PostgreSQL
resource — grant access on that server directly.
A data table role is a Postgres login on a cluster Windmill manages, so only a data table
on the Windmill instance or the external instance can use one. This one is backed by a
PostgreSQL resource — grant access on that server directly.
</Alert>
{:else if governing}
<Alert type="info" title="Governed by {governing}" size="xs">
@@ -283,7 +286,7 @@
{#if permissioned}
{#if editable && availableRoles.length === 0}
<Alert type="warning" title="No role defined on this instance" size="xs">
<Alert type="warning" title="No role defined on {clusterLabel}" size="xs">
Only <span class="font-mono">admin</span> can be used until a superadmin creates a data
table role. Type a name below to add one.
</Alert>
@@ -297,7 +300,7 @@
<Tooltip>
admin is the connection the data table used before roles, so it owns every
existing object and cannot be removed. Every other role is a login defined for
the whole instance, with only the privileges granted to it under Access.
the whole cluster, with only the privileges granted to it under Access.
</Tooltip>
</Cell>
<Cell head>
@@ -326,23 +329,23 @@
<span class="font-mono text-xs text-emphasis">{role.name ?? role.id}</span>
{#if !role.name}
<span class="text-2xs text-secondary italic">
no longer defined on this instance
no longer defined on {clusterLabel}
</span>
{:else if role.id === undefined}
<Alert type="warning" title="This role does not exist yet" size="xs">
{#if $superadmin}
<div class="flex flex-col items-start gap-1">
<span>Create it on the instance to use it here.</span>
<span>Create it on {clusterLabel} to use it here.</span>
<Button
unifiedSize="xs"
variant="default"
on:click={() => instanceRoles?.open(role.name)}
on:click={() => instanceRoles?.open(role.name, info?.cluster)}
>
Create it
</Button>
</div>
{:else}
Only a superadmin can create it on the instance.
Only a superadmin can create it on {clusterLabel}.
{/if}
</Alert>
{/if}
@@ -10,13 +10,16 @@
import Head from '../table/Head.svelte'
import Row from '../table/Row.svelte'
import { Pencil, Plus } from 'lucide-svelte'
import { SettingService, type InstanceDatatableRole } from '$lib/gen'
import { SettingService, type DatatableRoleCluster, type InstanceDatatableRole } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
let {
cluster = 'instance',
initialName = '',
onChanged
}: {
/** The cluster whose role catalog is shown. Fixed for the component's lifetime. */
cluster?: DatatableRoleCluster
/** Prefills the name of the role to add. */
initialName?: string
/** Called after every change to the catalog, whether or not it went through. */
@@ -38,7 +41,7 @@
loading = true
loadError = undefined
try {
roles = await SettingService.listInstanceDatatableRoles()
roles = await SettingService.listInstanceDatatableRoles({ cluster })
} catch (e) {
loadError = e?.body ?? e?.message ?? String(e)
} finally {
@@ -69,7 +72,7 @@
await run(
() =>
SettingService.createInstanceDatatableRole({
requestBody: { name }
requestBody: { name, cluster }
}),
`Created the data table role ${name}`
)
@@ -80,7 +83,7 @@
const confirmed = await confirmationModal.ask({
title: `Delete the role ${role.name}?`,
children:
'Everything it owns in every instance database is handed back to the admin connection, its grants are dropped, and it is removed from every data table that named it. This cannot be undone.',
'Everything it owns in every database of its cluster is handed back to the admin connection, its grants are dropped, and it is removed from every data table that named it. This cannot be undone.',
confirmationText: 'Delete role'
})
if (!confirmed) return
@@ -95,7 +98,7 @@
<div class="flex flex-col gap-2">
{#if loadError}
<Alert type="error" title="Could not load the instance roles" size="xs">{loadError}</Alert>
<Alert type="error" title="Could not load the data table roles" size="xs">{loadError}</Alert>
{:else}
<DataTable>
<Head>
@@ -9,7 +9,7 @@
id: string
name: string
database: {
resource_type: 'postgresql' | 'instance'
resource_type: 'postgresql' | 'instance' | 'external_instance'
resource_path?: string | undefined
}
/** Set on a fork's entry: it names the workspace whose data table governs this one, and
@@ -81,8 +81,10 @@
import {
isCustomInstanceDbEnabled,
getUnusedInstanceDbName,
isDataTableWizardEnabled
isDataTableWizardEnabled,
externalInstanceDbUnavailableReason
} from './utils.svelte'
import ExternalInstanceDbSelect from './ExternalInstanceDbSelect.svelte'
import { random_adj } from '../random_positive_adjetive'
import { sendUserToast } from '$lib/toast'
import {
@@ -420,6 +422,13 @@
>
Use Windmill's PostgreSQL instance
</Tooltip>
{:else if dataTable.database.resource_type === 'external_instance'}
<Tooltip
wrapperClass="absolute mt-[0.6rem] right-2 z-20"
placement="bottom-start"
>
Use a database on the external PostgreSQL cluster set in instance settings
</Tooltip>
{/if}
<Select
items={[
@@ -433,6 +442,12 @@
: isCloudHosted()
? 'Not available on cloud'
: 'Superadmin only'
},
{
value: 'external_instance',
label: 'External instance',
disabled: !!$externalInstanceDbUnavailableReason,
subtitle: $externalInstanceDbUnavailableReason
}
]}
bind:value={
@@ -446,16 +461,22 @@
}
}
id="database-type-select"
class="w-28"
class="w-40"
/>
</div>
<div class="flex items-center gap-1 w-80 relative">
{#if dataTable.database.resource_type !== 'instance'}
{#if dataTable.database.resource_type === 'postgresql'}
<ResourcePicker
class="flex-1"
bind:value={dataTable.database.resource_path}
resourceType={dataTable.database.resource_type}
/>
{:else if dataTable.database.resource_type === 'external_instance'}
<ExternalInstanceDbSelect
class="flex-1"
bind:value={dataTable.database.resource_path}
tag="datatable"
/>
{:else}
<CustomInstanceDbSelect
class="flex-1"
@@ -13,7 +13,7 @@
ducklakes: {
name: string
catalog: {
resource_type: 'postgresql' | 'mysql' | 'instance'
resource_type: 'postgresql' | 'mysql' | 'instance' | 'external_instance'
resource_path?: string // Name of the database when resource_type is instance
}
storage: {
@@ -105,7 +105,12 @@
import Popover from '../meltComponents/Popover.svelte'
import TextInput from '../text_input/TextInput.svelte'
import { slide } from 'svelte/transition'
import { isCustomInstanceDbEnabled, getUnusedInstanceDbName } from './utils.svelte'
import {
isCustomInstanceDbEnabled,
getUnusedInstanceDbName,
externalInstanceDbUnavailableReason
} from './utils.svelte'
import ExternalInstanceDbSelect from './ExternalInstanceDbSelect.svelte'
import { resource } from 'runed'
import CustomInstanceDbSelect from './CustomInstanceDbSelect.svelte'
import Label from '../Label.svelte'
@@ -283,10 +288,9 @@
This workspace is a fork, and these settings are its own copy. Lakes marked
<span class="font-semibold">isolated</span> read the parent's tables through defer views and
write to a fork-scoped namespace that is cleaned up when the fork is deleted. Lakes marked
<span class="font-semibold">shared with parent</span> read and write the parent's physical
lake directly — editing their catalog or storage here repoints the shared lake for this
fork's jobs. The choice is made per lake when the fork is created and cannot be changed
here.
<span class="font-semibold">shared with parent</span> read and write the parent's physical lake
directly — editing their catalog or storage here repoints the shared lake for this fork's jobs.
The choice is made per lake when the fork is created and cannot be changed here.
</Alert>
</div>
{/if}
@@ -359,8 +363,8 @@
isolated
</span>
<Tooltip>
Writes go to a fork-scoped namespace; reads of tables not yet materialized in
this fork defer to the parent. Deleting the fork cleans the namespace up.
Writes go to a fork-scoped namespace; reads of tables not yet materialized in this
fork defer to the parent. Deleting the fork cleans the namespace up.
</Tooltip>
{/if}
</div>
@@ -373,6 +377,11 @@
<Tooltip wrapperClass="absolute mt-[0.6rem] right-2 z-20" placement="bottom-start">
Use Windmill's PostgreSQL instance as a catalog
</Tooltip>
{:else if ducklake.catalog.resource_type === 'external_instance'}
<Tooltip wrapperClass="absolute mt-[0.6rem] right-2 z-20" placement="bottom-start">
Use a database on the external PostgreSQL cluster set in instance settings as a
catalog
</Tooltip>
{/if}
<Select
items={[
@@ -382,6 +391,12 @@
value: 'instance',
label: 'Instance',
subtitle: $isCustomInstanceDbEnabled ? undefined : 'Superadmin only'
},
{
value: 'external_instance',
label: 'External instance',
disabled: !!$externalInstanceDbUnavailableReason,
subtitle: $externalInstanceDbUnavailableReason
}
]}
bind:value={
@@ -394,16 +409,22 @@
}
}
}
class="w-24"
class="w-40"
/>
</div>
<div class="flex flex-1">
{#if ducklake.catalog.resource_type !== 'instance'}
{#if ducklake.catalog.resource_type === 'postgresql' || ducklake.catalog.resource_type === 'mysql'}
<ResourcePicker
class="flex-1 min-w-32"
bind:value={ducklake.catalog.resource_path}
resourceType={ducklake.catalog.resource_type}
/>
{:else if ducklake.catalog.resource_type === 'external_instance'}
<ExternalInstanceDbSelect
class="flex-1 min-w-32"
bind:value={ducklake.catalog.resource_path}
tag="ducklake"
/>
{:else}
<CustomInstanceDbSelect
class="flex-1 min-w-32"
@@ -0,0 +1,84 @@
<script lang="ts">
import { SettingService, type CustomInstanceDbTag } from '$lib/gen'
import { resource } from 'runed'
import Select from '../select/Select.svelte'
import { safeSelectItems } from '../select/utils.svelte'
import Button from '../common/button/Button.svelte'
import { sendUserToast } from '$lib/toast'
import { isExternalInstanceDbEnabled } from './utils.svelte'
import { Plus } from 'lucide-svelte'
type Props = {
value: string | undefined
tag: CustomInstanceDbTag
class?: string
}
let { value = $bindable(), tag, class: className }: Props = $props()
let refreshKey = $state(0)
const databases = resource(
() => refreshKey,
async () => {
try {
return await SettingService.listExternalInstancePgDatabases()
} catch {
return {}
}
}
)
// Every database Windmill created is offered, whatever it was created for: the tag only
// sorts the ones made for this kind of storage first.
let items = $derived(
safeSelectItems(
Object.entries(databases.current ?? {})
.sort(([, a], [, b]) => Number(b.tag === tag) - Number(a.tag === tag))
.map(([name]) => name)
)
)
let exists = $derived(!!value && !!databases.current?.[value])
let creating = $state(false)
async function create() {
if (!value) return
creating = true
try {
await SettingService.createExternalInstancePgDatabase({
name: value,
requestBody: { tag }
})
sendUserToast(`Created database ${value} on the external cluster`)
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
} finally {
creating = false
refreshKey++
}
}
</script>
<div class="flex items-center gap-1 {className}">
<Select
class="flex-1"
bind:value
onCreateItem={(i) => (value = i)}
placeholder="Search or create..."
showPlaceholderOnOpen
{items}
id="external-instance-db-select"
disabled={!$isExternalInstanceDbEnabled}
/>
{#if value && !databases.loading && !exists}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Plus }}
loading={creating}
disabled={!$isExternalInstanceDbEnabled}
title="Create this database on the external cluster"
onclick={create}
>
Create
</Button>
{/if}
</div>
@@ -1,6 +1,9 @@
<script lang="ts">
import { Badge, Button, Drawer, DrawerContent } from '../common'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import { Users } from 'lucide-svelte'
import { SettingService, type DatatableRoleCluster } from '$lib/gen'
import DataTableRolesSection from './DataTableRolesSection.svelte'
let {
@@ -15,14 +18,20 @@
let drawer: Drawer | undefined = $state(undefined)
let prefill = $state('')
let cluster = $state<DatatableRoleCluster>('instance')
let externalConfigured = $state(false)
// Remounts the section on each open, so the prefilled name is the one just asked for.
let openCount = $state(0)
/** Opens the drawer, with `name` prefilled as the role to add. */
export function open(name = '') {
/** Opens the drawer on `targetCluster`'s catalog, with `name` prefilled as the role to add. */
export function open(name = '', targetCluster: DatatableRoleCluster = 'instance') {
prefill = name
cluster = targetCluster
openCount++
drawer?.openDrawer()
SettingService.getExternalInstancePgStatus()
.then((s) => (externalConfigured = s.configured))
.catch(() => (externalConfigured = false))
}
</script>
@@ -36,13 +45,30 @@
<DrawerContent
title="Instance roles"
on:close={() => drawer?.closeDrawer()}
tooltip="A data table role is a real Postgres login on this instance, shared by every instance database. A job that names one connects as it, and Postgres decides what it may touch. Which people may use a role on a given data table, and what it may do there, is set per data table, in its roles drawer."
tooltip="A data table role is a real Postgres login on one cluster Windmill manages: this instance's own, shared by every instance database, or the external instance cluster, shared by every external instance database. A job that names one connects as it, and Postgres decides what it may touch. Which people may use a role on a given data table, and what it may do there, is set per data table, in its roles drawer."
>
{#snippet titleExtra()}
<Badge color="blue" small>Beta</Badge>
{/snippet}
{#key openCount}
<DataTableRolesSection initialName={prefill} {onChanged} />
{/key}
<div class="flex flex-col gap-4">
{#if externalConfigured || cluster === 'external_instance'}
<ToggleButtonGroup
noWFull
selected={cluster}
onSelected={(v) => {
prefill = ''
cluster = v
}}
>
{#snippet children({ item })}
<ToggleButton value="instance" label="Windmill instance" {item} />
<ToggleButton value="external_instance" label="External instance" {item} />
{/snippet}
</ToggleButtonGroup>
{/if}
{#key `${openCount}:${cluster}`}
<DataTableRolesSection {cluster} initialName={prefill} {onChanged} />
{/key}
</div>
</DrawerContent>
</Drawer>
@@ -1,6 +1,6 @@
import { isCloudHosted } from '$lib/cloud'
import { superadmin } from '$lib/stores'
import { enterpriseLicense, superadmin } from '$lib/stores'
import { getLocalSetting } from '$lib/utils'
import { derived } from 'svelte/store'
@@ -20,6 +20,24 @@ export let isCustomInstanceDbEnabled = derived(
([superadmin_]) => superadmin_ && !isCloudHosted()
)
export let isExternalInstanceDbEnabled = derived(
[superadmin, enterpriseLicense],
([superadmin_, enterpriseLicense_]) => superadmin_ && !!enterpriseLicense_ && !isCloudHosted()
)
/** Why the External instance option cannot be picked, or undefined when it can. */
export let externalInstanceDbUnavailableReason = derived(
[superadmin, enterpriseLicense],
([superadmin_, enterpriseLicense_]) =>
isCloudHosted()
? 'Not available on cloud'
: !enterpriseLicense_
? 'Enterprise Edition only'
: !superadmin_
? 'Superadmin only'
: undefined
)
// Postgres caps identifiers at 63 bytes; the backend rejects longer db names.
const MAX_INSTANCE_DB_NAME_LEN = 63