Skip to content
Merged

Dev #120

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions src/domain/postgres/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -151,3 +161,23 @@ pub async fn run(
})
.await?
}

pub(crate) fn exclude_args(cfg: &DatabaseConfig) -> (Vec<String>, 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")] {
Comment thread
RambokDev marked this conversation as resolved.
match cfg.options.get(key).map(|v| v.as_array()) {
None => {}
Some(Some(items)) => args.extend(
items
.iter()
.filter_map(|v| v.as_str())
Comment thread
RambokDev marked this conversation as resolved.
.map(str::trim)
.filter(|p| !p.is_empty())
.map(|p| format!("{flag}={p}")),
),
Some(None) => invalid.push(key),
}
}
(args, invalid)
}
111 changes: 111 additions & 0 deletions src/tests/domain/postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -924,6 +980,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;

Expand Down
Loading