diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index 64d7854173..882207ebfe 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -1583,3 +1583,49 @@ async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool

) { + clear_settings_and_configs(&db).await; + let cluster = |host: &str, password: &str| serde_json::json!({ "host": host, "port": 5432, "user": "wm_admin", "password": password }); + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES + ('external_instance_pg', $1), + ('external_instance_pg_state', '{\"databases\": {\"dt_a\": {\"success\": true}}}')", + ) + .bind(cluster("pg-a.internal", "one")) + .execute(&db) + .await + .unwrap(); + let current = BTreeMap::from([( + "external_instance_pg".to_string(), + cluster("pg-a.internal", "one"), + )]); + let sync = |value: serde_json::Value| { + let desired = BTreeMap::from([("external_instance_pg".to_string(), value)]); + let (db, current) = (db.clone(), current.clone()); + async move { + windmill_common::instance_config::sync_global_settings_declarative( + &db, ¤t, &desired, + ) + .await + } + }; + + let err = sync(cluster("pg-b.internal", "one")) + .await + .expect_err("another host must be refused while dt_a is registered"); + assert!(err.to_string().contains("dt_a"), "got: {err}"); + assert_eq!( + get_global_setting(&db, "external_instance_pg").await, + Some(cluster("pg-a.internal", "one")) + ); + + sync(cluster("PG-A.internal ", "two")) + .await + .expect("a new login on the same cluster must sync"); +} diff --git a/backend/windmill-common/src/external_instance_pg.rs b/backend/windmill-common/src/external_instance_pg.rs index 454e5c4b81..b52a847996 100644 --- a/backend/windmill-common/src/external_instance_pg.rs +++ b/backend/windmill-common/src/external_instance_pg.rs @@ -161,11 +161,39 @@ pub async fn check_external_instance_pg_write( ensure_external_instance_pg_removable(db).await } Some(value) => { - crate::external_instance_pg_oss::validate_external_instance_pg_setting(value) + crate::external_instance_pg_oss::validate_external_instance_pg_setting(value)?; + ensure_same_cluster_while_in_use(db, value).await } } } +/// Pointing the setting at another cluster strands the databases Windmill created on this one as +/// surely as unsetting it, and would hand their registry and role passwords to a cluster that has +/// neither. The login, TLS and maintenance database may change; the host and port may not. +async fn ensure_same_cluster_while_in_use(db: &DB, value: &serde_json::Value) -> Result<()> { + let state = read_external_instance_pg_state(db).await?; + if state.databases.is_empty() { + return Ok(()); + } + let identity = |c: &ExternalInstancePg| (c.host.trim().to_lowercase(), c.port.unwrap_or(5432)); + let new: ExternalInstancePg = serde_json::from_value(value.clone()) + .map_err(|e| Error::BadRequest(format!("{EXTERNAL_INSTANCE_PG_SETTING}: {e}")))?; + let current = read_external_instance_pg_config(db).await?; + if current.is_some_and(|current| identity(¤t) == identity(&new)) { + return Ok(()); + } + Err(Error::BadRequest(format!( + "The external instance cluster still holds databases Windmill created ({}). Drop them \ + before pointing {EXTERNAL_INSTANCE_PG_SETTING} at another host or port.", + state + .databases + .keys() + .cloned() + .collect::>() + .join(", ") + ))) +} + /// Converge the external cluster on the configured login: check what it can do, create or update /// Windmill's two roles with the stored passwords, and report anything that would get in the way. /// With `rotate_passwords`, generate new passwords first. Safe to run again; running it again is