From 9cda3cca618c8adcd98602aaba984c12f384f689 Mon Sep 17 00:00:00 2001 From: Charles Gauthereau Date: Fri, 16 Oct 2026 16:32:07 +0200 Subject: [PATCH 1/3] feat(postgres): parse exclude_tables/exclude_schemas options into pg_dump args --- src/domain/postgres/backup.rs | 22 ++++++++++++++ src/tests/domain/postgres.rs | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/domain/postgres/backup.rs b/src/domain/postgres/backup.rs index 906cfd9..b2afdcf 100644 --- a/src/domain/postgres/backup.rs +++ b/src/domain/postgres/backup.rs @@ -151,3 +151,25 @@ pub async fn run( }) .await? } + +/// `--exclude-table=` / `--exclude-schema=` args from `options`, plus the keys +/// whose value was not a list of strings (ignored, caller warns). +pub(crate) fn exclude_args(cfg: &DatabaseConfig) -> (Vec, Vec<&'static str>) { + let mut args = Vec::new(); + let mut invalid = Vec::new(); + for (key, flag) in [("exclude_tables", "--exclude-table"), ("exclude_schemas", "--exclude-schema")] { + match cfg.options.get(key).map(|v| v.as_array()) { + None => {} + Some(Some(items)) => args.extend( + items + .iter() + .filter_map(|v| v.as_str()) + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(|p| format!("{flag}={p}")), + ), + Some(None) => invalid.push(key), + } + } + (args, invalid) +} diff --git a/src/tests/domain/postgres.rs b/src/tests/domain/postgres.rs index ce33325..559bffc 100644 --- a/src/tests/domain/postgres.rs +++ b/src/tests/domain/postgres.rs @@ -924,6 +924,61 @@ mod clean_mode_tests { } } +mod exclude_args_tests { + use crate::domain::postgres::backup::exclude_args; + use crate::services::config::DatabaseConfig; + use serde_json::json; + + fn cfg(options: serde_json::Value) -> DatabaseConfig { + serde_json::from_value(json!({ + "name": "t", "database": "testdb", "type": "postgresql", + "username": "u", "password": "p", "port": 5432, "host": "localhost", + "generated_id": "00000000-0000-0000-0000-000000000000", + "path": "", "max_packet_size": "", "volume_name": "", + "options": options + })) + .unwrap() + } + + #[test] + fn no_options_yields_nothing() { + assert_eq!(exclude_args(&cfg(json!({}))), (vec![], vec![])); + assert_eq!( + exclude_args(&cfg(json!({"exclude_tables": [], "exclude_schemas": []}))), + (vec![], vec![]) + ); + } + + #[test] + fn lists_map_to_single_arg_flags_in_order() { + let (args, invalid) = exclude_args(&cfg(json!({ + "exclude_schemas": ["metabase_tmp"], + "exclude_tables": ["public.search_index__*", " audit.tmp_* ", "", " ", 42, "-f/tmp/x", "\"MyTable\""] + }))); + assert_eq!( + args, + vec![ + "--exclude-table=public.search_index__*", + "--exclude-table=audit.tmp_*", + "--exclude-table=-f/tmp/x", + "--exclude-table=\"MyTable\"", + "--exclude-schema=metabase_tmp", + ] + ); + assert!(invalid.is_empty()); + } + + #[test] + fn non_list_values_are_reported_and_ignored() { + let (args, invalid) = exclude_args(&cfg(json!({ + "exclude_tables": "public.search_index__*", + "exclude_schemas": {"a": 1} + }))); + assert!(args.is_empty()); + assert_eq!(invalid, vec!["exclude_tables", "exclude_schemas"]); + } +} + mod toc_tests { use crate::domain::postgres::restore::toc_creates_public_schema; From bd76ac365be09f7a63f527b7ceb580d6754ab11a Mon Sep 17 00:00:00 2001 From: Charles Gauthereau Date: Fri, 16 Oct 2026 16:34:54 +0200 Subject: [PATCH 2/3] feat(postgres): pass exclude_tables/exclude_schemas to pg_dump (#455) --- src/domain/postgres/backup.rs | 10 +++++++ src/tests/domain/postgres.rs | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/domain/postgres/backup.rs b/src/domain/postgres/backup.rs index b2afdcf..d4fc30f 100644 --- a/src/domain/postgres/backup.rs +++ b/src/domain/postgres/backup.rs @@ -34,6 +34,14 @@ pub async fn run( let pg_dump = select_pg_path(&version).join("pg_dump"); logger.log("debug", format!("Using pg_dump at {:?}", pg_dump)); + let (excludes, invalid) = exclude_args(&cfg); + for key in invalid { + logger.log("warn", format!("Ignoring options.{} for {}: expected a list of patterns", key, cfg.name)); + } + if !excludes.is_empty() { + logger.log("info", format!("Excluding from dump of {}: {}", cfg.name, excludes.join(" "))); + } + match format { PostgresDumpFormat::Fc => { logger.log("info", format!("Running FC backup for {}", cfg.name)); @@ -46,6 +54,7 @@ pub async fn run( .arg("--port").arg(cfg.port.to_string()) .arg("--username").arg(&cfg.username) .arg("--dbname").arg(&cfg.database) + .args(&excludes) .arg("-Fc") .arg("-f").arg(&file_path) .arg("-v") @@ -97,6 +106,7 @@ pub async fn run( .arg("--port").arg(cfg.port.to_string()) .arg("--username").arg(&cfg.username) .arg("--dbname").arg(&cfg.database) + .args(&excludes) .arg("-Fd") .arg("-j").arg("4") .arg("-f").arg(&dump_dir) diff --git a/src/tests/domain/postgres.rs b/src/tests/domain/postgres.rs index 559bffc..d34429a 100644 --- a/src/tests/domain/postgres.rs +++ b/src/tests/domain/postgres.rs @@ -316,6 +316,62 @@ async fn restore_run_unified_fc_roundtrip() { assert!(result.is_ok(), "restore::run failed: {:?}", result); } +#[tokio::test] +async fn backup_as_non_owner_succeeds_only_with_exclude_tables() { + init_tracing_for_test(); + + let (_container, admin) = create_config().await; + crate::domain::postgres::connection::connect(&admin) + .await + .unwrap() + .batch_execute( + "CREATE ROLE backup_ro LOGIN PASSWORD 'x'; + CREATE TABLE public.keep(id int); + CREATE TABLE public.search_index__x(id int); + GRANT SELECT ON public.keep TO backup_ro;", + ) + .await + .unwrap(); + + let mut ro = admin.clone(); + ro.username = "backup_ro".into(); + ro.password = "x".into(); + + let mut excluded = ro.clone(); + excluded + .options + .insert("exclude_tables".into(), serde_json::json!(["public.search_index__*"])); + + for format in [PostgresDumpFormat::Fc, PostgresDumpFormat::Fd] { + let dir = TempDir::new().unwrap(); + let denied = crate::domain::postgres::backup::run( + ro.clone(), format, dir.path().to_path_buf(), pg_dump_env(&ro), Arc::new(JobLogger::new()), + ) + .await; + assert!(denied.is_err(), "{format:?}: non-owner dump must fail without exclusions"); + + let dir = TempDir::new().unwrap(); + let logger = Arc::new(JobLogger::new()); + let dump = crate::domain::postgres::backup::run( + excluded.clone(), format, dir.path().to_path_buf(), pg_dump_env(&excluded), logger.clone(), + ) + .await + .unwrap_or_else(|e| panic!("{format:?}: dump with exclusions failed: {e:?}")); + + let entries = Arc::try_unwrap(logger).expect("single owner").into_entries(); + assert!(entries.iter().any(|e| e.message + == format!("Excluding from dump of {}: --exclude-table=public.search_index__*", excluded.name))); + + if format == PostgresDumpFormat::Fc { + let version = server_version(&admin).await.unwrap(); + let pg_restore = select_pg_path(&version).join(pg_restore_binary_name()); + let prepared = prepare_archive(format, &dump, &pg_restore, &JobLogger::new()).unwrap(); + assert!(prepared.toc().contains("TABLE public keep ")); + assert!(!prepared.toc().contains("search_index__x")); + } + } +} + #[tokio::test] async fn drop_all_schemas_removes_user_schema() { init_tracing_for_test(); From ba02799c78b77f1987ffe1b9b386e5ae61cf812c Mon Sep 17 00:00:00 2001 From: Charles Gauthereau Date: Fri, 16 Oct 2026 17:20:04 +0200 Subject: [PATCH 3/3] fix: refactoring --- docker-compose.yml | 2 +- src/domain/postgres/backup.rs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 2adce57..4b18ba7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: LOG: debug TZ: "Europe/Paris" # TMPDIR: /scratch - EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiMjcyMWFiNGQtZDkzNi00NmFkLWE1MWEtOWVhYTk3ZjJhMjAwIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==" + EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiN2ZjMGViODMtYTY0YS00NzI3LTg5ZjktNzFhZWJmZDQ3NmEyIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==" #CHUNK_SIZE_MB: "1" #POOLING: 1 #RETRY_ATTEMPTS: 3 diff --git a/src/domain/postgres/backup.rs b/src/domain/postgres/backup.rs index d4fc30f..0903a9e 100644 --- a/src/domain/postgres/backup.rs +++ b/src/domain/postgres/backup.rs @@ -162,8 +162,6 @@ pub async fn run( .await? } -/// `--exclude-table=` / `--exclude-schema=` args from `options`, plus the keys -/// whose value was not a list of strings (ignored, caller warns). pub(crate) fn exclude_args(cfg: &DatabaseConfig) -> (Vec, Vec<&'static str>) { let mut args = Vec::new(); let mut invalid = Vec::new();