From 6388aff1e34376619d75b0acecdf66fc7969d32d Mon Sep 17 00:00:00 2001 From: charles-gauthereau Date: Mon, 29 Jun 2026 17:10:11 +0200 Subject: [PATCH 1/5] fix: postgres cluster dump command --- databases.json | 2 +- src/domain/postgres/cluster/restore.rs | 8 +++++++- src/domain/postgres/connection.rs | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/databases.json b/databases.json index 70eface..351c8b0 100644 --- a/databases.json +++ b/databases.json @@ -3,7 +3,7 @@ { "name": "Test database 1 - PostgreSQL", "database": "devdb", - "type": "postgresql", + "type": "postgresql-cluster", "username": "devuser", "password": "changeme", "port": 5432, diff --git a/src/domain/postgres/cluster/restore.rs b/src/domain/postgres/cluster/restore.rs index e24efa8..42bdadd 100644 --- a/src/domain/postgres/cluster/restore.rs +++ b/src/domain/postgres/cluster/restore.rs @@ -5,7 +5,7 @@ use std::process::Command; use std::sync::Arc; use std::time::Instant; -use super::super::connection::{is_superuser, psql_binary_name, select_pg_path, server_version}; +use super::super::connection::{is_superuser, psql_binary_name, select_pg_path, server_version, terminate_all_connections}; use crate::services::backup::logger::JobLogger; use crate::services::config::DatabaseConfig; @@ -40,6 +40,12 @@ pub async fn run( let psql = select_pg_path(&version).join(psql_binary_name()); + if let Err(e) = futures::executor::block_on(terminate_all_connections(&cfg)) { + logger.log("error", format!("Failed to terminate connections for cluster {}: {:?}", cfg.name, e)); + return Err(e.into()); + } + logger.log("info", format!("All user database connections terminated for cluster {}", cfg.name)); + logger.log("info", format!("Replaying cluster dump for {} via {:?}", cfg.name, psql)); let start = Instant::now(); diff --git a/src/domain/postgres/connection.rs b/src/domain/postgres/connection.rs index 0b0aebd..f4f52fa 100644 --- a/src/domain/postgres/connection.rs +++ b/src/domain/postgres/connection.rs @@ -144,6 +144,27 @@ pub async fn terminate_connections(cfg: &DatabaseConfig) -> Result<()> { Ok(()) } +pub async fn terminate_all_connections(cfg: &DatabaseConfig) -> Result<()> { + let mut admin = cfg.clone(); + admin.database = "postgres".to_string().into(); + + let client = connect(&admin).await?; + + client + .execute( + r#" + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname NOT IN ('postgres', 'template0', 'template1') + AND pid <> pg_backend_pid(); + "#, + &[], + ) + .await?; + + Ok(()) +} + pub fn detect_format_from_file(restore_file: &Path) -> PostgresDumpFormat { match restore_file.extension().and_then(|e| e.to_str()) { Some("dump") => PostgresDumpFormat::Fc, From aed1e869564e12a7cd27bc2123bbc7ce45158599 Mon Sep 17 00:00:00 2001 From: charles-gauthereau Date: Mon, 29 Jun 2026 18:49:01 +0200 Subject: [PATCH 2/5] feat: add generic options map to DatabaseConfig Add `options: HashMap` to DatabaseConfig and `options: Option>` to InputDatabaseConfig so callers can pass arbitrary per-database options (e.g. keep_ownership for pg_dumpall). Update all DatabaseConfig struct literals in test files and add three new config-service unit tests covering present, absent, and non-bool values. --- src/services/config.rs | 4 ++ src/tests/domain/cluster/database.rs | 1 + src/tests/domain/cluster/mod.rs | 1 + src/tests/domain/firebird.rs | 1 + src/tests/domain/mariadb.rs | 1 + src/tests/domain/mongodb.rs | 1 + src/tests/domain/mssql.rs | 1 + src/tests/domain/mysql.rs | 1 + src/tests/domain/postgres.rs | 2 + src/tests/domain/redis.rs | 1 + src/tests/domain/valkey.rs | 1 + src/tests/services/config_tests.rs | 93 ++++++++++++++++++++++++++++ 12 files changed, 108 insertions(+) diff --git a/src/services/config.rs b/src/services/config.rs index eea6eb6..a6a3eac 100644 --- a/src/services/config.rs +++ b/src/services/config.rs @@ -3,6 +3,7 @@ use crate::core::context::Context; use serde::Deserialize; use serde_json; +use std::collections::HashMap; use std::fs::File; use std::io::Read; use std::path::Path; @@ -58,6 +59,7 @@ pub struct DatabaseConfig { pub generated_id: String, pub path: String, pub max_packet_size: String, + pub options: HashMap, } #[allow(dead_code)] @@ -80,6 +82,7 @@ pub struct InputDatabaseConfig { pub generated_id: String, pub path: Option, pub max_packet_size: Option, + pub options: Option>, } #[allow(dead_code)] @@ -247,6 +250,7 @@ impl ConfigService { generated_id: db.generated_id, path: path_val, max_packet_size, + options: db.options.unwrap_or_default(), }); } diff --git a/src/tests/domain/cluster/database.rs b/src/tests/domain/cluster/database.rs index 2f40cfd..d8767fe 100644 --- a/src/tests/domain/cluster/database.rs +++ b/src/tests/domain/cluster/database.rs @@ -14,6 +14,7 @@ fn cluster_config() -> DatabaseConfig { generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(), path: String::new(), max_packet_size: String::new(), + options: std::collections::HashMap::new(), } } diff --git a/src/tests/domain/cluster/mod.rs b/src/tests/domain/cluster/mod.rs index e26cd04..9153182 100644 --- a/src/tests/domain/cluster/mod.rs +++ b/src/tests/domain/cluster/mod.rs @@ -36,6 +36,7 @@ async fn start_cluster(user: &str) -> (ContainerAsync, DatabaseConfig) generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(), path: "".to_string(), max_packet_size: "".to_string(), + options: std::collections::HashMap::new(), }; (container, config) } diff --git a/src/tests/domain/firebird.rs b/src/tests/domain/firebird.rs index 2ae4320..f8cf36b 100644 --- a/src/tests/domain/firebird.rs +++ b/src/tests/domain/firebird.rs @@ -40,6 +40,7 @@ async fn create_config() -> (ContainerAsync, DatabaseConfig) { generated_id: "3c445eb4-c2c6-4bde-a423-ee1385dcf6d2".to_string(), path: "".to_string(), max_packet_size: "".to_string(), + options: std::collections::HashMap::new(), }; (container, config) diff --git a/src/tests/domain/mariadb.rs b/src/tests/domain/mariadb.rs index 1989bbd..f9f4a54 100644 --- a/src/tests/domain/mariadb.rs +++ b/src/tests/domain/mariadb.rs @@ -32,6 +32,7 @@ async fn create_config() -> (ContainerAsync, DatabaseConfig) { generated_id: "3c4b4eb4-c2c6-4bde-a423-ee1385dcf6d2".to_string(), path: "".to_string(), max_packet_size: "512M".to_string(), + options: std::collections::HashMap::new(), }; (container, config) diff --git a/src/tests/domain/mongodb.rs b/src/tests/domain/mongodb.rs index d2d0688..d90e119 100644 --- a/src/tests/domain/mongodb.rs +++ b/src/tests/domain/mongodb.rs @@ -30,6 +30,7 @@ async fn create_config() -> (ContainerAsync, DatabaseConfig) { generated_id: "96d30a9f-ff4b-47c9-aaab-f3147bb34f16".to_string(), path: "".to_string(), max_packet_size: "".to_string(), + options: std::collections::HashMap::new(), }; (container, config) diff --git a/src/tests/domain/mssql.rs b/src/tests/domain/mssql.rs index d6a4001..45b060c 100644 --- a/src/tests/domain/mssql.rs +++ b/src/tests/domain/mssql.rs @@ -55,6 +55,7 @@ fn make_config(host: String, port: u16, database: &str, generated_id: &str) -> D generated_id: generated_id.to_string(), path: "".to_string(), max_packet_size: "".to_string(), + options: std::collections::HashMap::new(), } } diff --git a/src/tests/domain/mysql.rs b/src/tests/domain/mysql.rs index 7a48bea..3bda55e 100644 --- a/src/tests/domain/mysql.rs +++ b/src/tests/domain/mysql.rs @@ -32,6 +32,7 @@ async fn create_config() -> (ContainerAsync, DatabaseConfig) { generated_id: "0f1bb8f2-35a0-4c91-8098-e36873d3ce31".to_string(), path: "".to_string(), max_packet_size: "512M".to_string(), + options: std::collections::HashMap::new(), }; (container, config) diff --git a/src/tests/domain/postgres.rs b/src/tests/domain/postgres.rs index 6e48d05..0515be7 100644 --- a/src/tests/domain/postgres.rs +++ b/src/tests/domain/postgres.rs @@ -39,6 +39,7 @@ async fn create_config() -> (ContainerAsync, DatabaseConfig) { generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(), path: "".to_string(), max_packet_size: "".to_string(), + options: std::collections::HashMap::new(), }; (container, config) @@ -156,6 +157,7 @@ async fn postgres_password_with_slash_test() { generated_id: "5a1f0e3c-9b8a-4a8e-9b1b-0a1c2d3e4f5a".to_string(), path: "".to_string(), max_packet_size: "".to_string(), + options: std::collections::HashMap::new(), }; let db = DatabaseFactory::create_for_backup(config.clone()).await; diff --git a/src/tests/domain/redis.rs b/src/tests/domain/redis.rs index 9cd63ee..51dac98 100644 --- a/src/tests/domain/redis.rs +++ b/src/tests/domain/redis.rs @@ -29,6 +29,7 @@ async fn create_config() -> (ContainerAsync, DatabaseConfig) { generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(), path: "".to_string(), max_packet_size: "".to_string(), + options: std::collections::HashMap::new(), }; (container, config) diff --git a/src/tests/domain/valkey.rs b/src/tests/domain/valkey.rs index 4a78369..2a1ec88 100644 --- a/src/tests/domain/valkey.rs +++ b/src/tests/domain/valkey.rs @@ -28,6 +28,7 @@ async fn create_config() -> (ContainerAsync, DatabaseConfig) { generated_id: "40875485-e3d2-4dfe-a26b-2a347ecc64fd".to_string(), path: "".to_string(), max_packet_size: "".to_string(), + options: std::collections::HashMap::new(), }; (container, config) diff --git a/src/tests/services/config_tests.rs b/src/tests/services/config_tests.rs index 6c5f79b..9dd2a37 100644 --- a/src/tests/services/config_tests.rs +++ b/src/tests/services/config_tests.rs @@ -77,3 +77,96 @@ fn postgresql_cluster_respects_explicit_database() { assert_eq!(cfg.databases[0].database, "maintenance"); } + +#[test] +fn postgresql_options_keep_ownership_parses() { + let file = write_json( + r#"{ + "databases": [ + { + "name": "db1", + "type": "postgresql", + "username": "u", + "password": "p", + "port": 5432, + "host": "localhost", + "database": "mydb", + "generated_id": "16678159-ff7e-4c97-8c83-0adeff214681", + "options": { + "keep_ownership": true + } + } + ] + }"#, + ); + + let service = ConfigService::new(test_context()); + let cfg = service.load(Some(file.path().to_str().unwrap())).unwrap(); + + let keep = cfg.databases[0] + .options + .get("keep_ownership") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + assert!(keep); +} + +#[test] +fn postgresql_options_absent_defaults_to_empty() { + let file = write_json( + r#"{ + "databases": [ + { + "name": "db1", + "type": "postgresql", + "username": "u", + "password": "p", + "port": 5432, + "host": "localhost", + "database": "mydb", + "generated_id": "16678159-ff7e-4c97-8c83-0adeff214681" + } + ] + }"#, + ); + + let service = ConfigService::new(test_context()); + let cfg = service.load(Some(file.path().to_str().unwrap())).unwrap(); + + assert!(cfg.databases[0].options.is_empty()); +} + +#[test] +fn postgresql_options_non_bool_keep_ownership_falls_back_to_false() { + let file = write_json( + r#"{ + "databases": [ + { + "name": "db1", + "type": "postgresql", + "username": "u", + "password": "p", + "port": 5432, + "host": "localhost", + "database": "mydb", + "generated_id": "16678159-ff7e-4c97-8c83-0adeff214681", + "options": { + "keep_ownership": "yes" + } + } + ] + }"#, + ); + + let service = ConfigService::new(test_context()); + let cfg = service.load(Some(file.path().to_str().unwrap())).unwrap(); + + let keep = cfg.databases[0] + .options + .get("keep_ownership") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + assert!(!keep); +} From 694b463787523c9af943dc12cfdccbc776851f16 Mon Sep 17 00:00:00 2001 From: charles-gauthereau Date: Mon, 29 Jun 2026 18:52:37 +0200 Subject: [PATCH 3/5] feat: conditionally omit --no-owner/--no-privileges in pg_restore based on keep_ownership option --- src/domain/postgres/restore.rs | 21 +++++++++++++++------ src/tests/services/config_tests.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/domain/postgres/restore.rs b/src/domain/postgres/restore.rs index 9a005ec..dd1e365 100644 --- a/src/domain/postgres/restore.rs +++ b/src/domain/postgres/restore.rs @@ -41,13 +41,20 @@ pub async fn run( } logger.log("info", format!("Connections terminated for database {}", cfg.name)); + let keep_ownership = cfg.options + .get("keep_ownership") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + match format { PostgresDumpFormat::Fc => { logger.log("info", format!("Running FC restore for {}", cfg.name)); let start = Instant::now(); - let output = Command::new(&pg_restore) - .arg("--no-owner") - .arg("--no-privileges") + let mut cmd = Command::new(&pg_restore); + if !keep_ownership { + cmd.arg("--no-owner").arg("--no-privileges"); + } + let output = cmd .arg("--clean") .arg("--if-exists") // .arg("--create") @@ -147,9 +154,11 @@ pub async fn run( }; let start = Instant::now(); - let output = Command::new(&pg_restore) - .arg("--no-owner") - .arg("--no-privileges") + let mut cmd = Command::new(&pg_restore); + if !keep_ownership { + cmd.arg("--no-owner").arg("--no-privileges"); + } + let output = cmd .arg("--clean") .arg("--if-exists") // .arg("--create") diff --git a/src/tests/services/config_tests.rs b/src/tests/services/config_tests.rs index 9dd2a37..045ac4f 100644 --- a/src/tests/services/config_tests.rs +++ b/src/tests/services/config_tests.rs @@ -170,3 +170,32 @@ fn postgresql_options_non_bool_keep_ownership_falls_back_to_false() { assert!(!keep); } + +#[test] +fn keep_ownership_extraction_logic() { + use serde_json::Value; + use std::collections::HashMap; + + // true → keep ownership + let mut opts: HashMap = HashMap::new(); + opts.insert("keep_ownership".to_string(), Value::Bool(true)); + let keep = opts.get("keep_ownership").and_then(|v| v.as_bool()).unwrap_or(false); + assert!(keep, "should keep ownership when flag is true"); + + // false → strip + let mut opts2: HashMap = HashMap::new(); + opts2.insert("keep_ownership".to_string(), Value::Bool(false)); + let keep2 = opts2.get("keep_ownership").and_then(|v| v.as_bool()).unwrap_or(false); + assert!(!keep2, "should strip when flag is false"); + + // missing → strip + let opts3: HashMap = HashMap::new(); + let keep3 = opts3.get("keep_ownership").and_then(|v| v.as_bool()).unwrap_or(false); + assert!(!keep3, "should strip when key absent"); + + // wrong type → strip + let mut opts4: HashMap = HashMap::new(); + opts4.insert("keep_ownership".to_string(), Value::String("yes".to_string())); + let keep4 = opts4.get("keep_ownership").and_then(|v| v.as_bool()).unwrap_or(false); + assert!(!keep4, "should strip when value is not bool"); +} From ee519964014a6ceadb14874fd8d8bd4ff9569be6 Mon Sep 17 00:00:00 2001 From: charles-gauthereau Date: Mon, 29 Jun 2026 18:54:01 +0200 Subject: [PATCH 4/5] chore: document keep_ownership option in example databases.json --- databases.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/databases.json b/databases.json index 351c8b0..b4a4739 100644 --- a/databases.json +++ b/databases.json @@ -3,12 +3,15 @@ { "name": "Test database 1 - PostgreSQL", "database": "devdb", - "type": "postgresql-cluster", + "type": "postgresql", "username": "devuser", "password": "changeme", "port": 5432, "host": "db-postgres", - "generated_id": "16678159-ff7e-4c97-8c83-0adeff214681" + "generated_id": "16678159-ff7e-4c97-8c83-0adeff214681", + "options": { + "keep_ownership": false + } }, { "name": "Test database 2 - MariaDB", From 34f353b68fe0c77187649fe520f61d70ccc124bb Mon Sep 17 00:00:00 2001 From: charles-gauthereau Date: Mon, 29 Jun 2026 19:10:49 +0200 Subject: [PATCH 5/5] fix: postgres keep_ownership flag --- databases.json | 14 ++++++++++++-- docker-compose.databases.yml | 15 +++++++++++++++ src/domain/postgres/restore.rs | 6 ++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/databases.json b/databases.json index b4a4739..7eee925 100644 --- a/databases.json +++ b/databases.json @@ -8,9 +8,19 @@ "password": "changeme", "port": 5432, "host": "db-postgres", - "generated_id": "16678159-ff7e-4c97-8c83-0adeff214681", + "generated_id": "16678159-ff7e-4c97-8c83-0adeff214681" + }, + { + "name": "Test database 1 - PostgreSQL - BIS", + "database": "devdb2", + "type": "postgresql", + "username": "devuser2", + "password": "changeme2", + "port": 5432, + "host": "db-postgres-2", + "generated_id": "16678159-ff7e-5697-8c83-0adeff214681", "options": { - "keep_ownership": false + "keep_ownership": true } }, { diff --git a/docker-compose.databases.yml b/docker-compose.databases.yml index 0d551c9..9f13c3c 100644 --- a/docker-compose.databases.yml +++ b/docker-compose.databases.yml @@ -15,6 +15,20 @@ services: networks: - portabase + db-postgres-2: + container_name: db-postgres-2 + image: postgres:17-alpine + ports: + - "5438:5432" + volumes: + - postgres-data-2:/var/lib/postgresql/data + environment: + - POSTGRES_DB=devdb2 + - POSTGRES_USER=devuser2 + - POSTGRES_PASSWORD=changeme2 + networks: + - portabase + db-mariadb: container_name: db-mariadb image: mariadb:latest @@ -179,6 +193,7 @@ services: volumes: postgres-data: + postgres-data-2: mariadb-data: mysql-data: mongodb-data: diff --git a/src/domain/postgres/restore.rs b/src/domain/postgres/restore.rs index dd1e365..4dea741 100644 --- a/src/domain/postgres/restore.rs +++ b/src/domain/postgres/restore.rs @@ -46,6 +46,12 @@ pub async fn run( .and_then(|v| v.as_bool()) .unwrap_or(false); + if keep_ownership { + logger.log("info", format!("Restoring ownership and privileges for {}", cfg.name)); + } else { + logger.log("info", format!("Stripping ownership and privileges for {} (--no-owner --no-privileges)", cfg.name)); + } + match format { PostgresDumpFormat::Fc => { logger.log("info", format!("Running FC restore for {}", cfg.name));