Compare commits

...

6 Commits

Author SHA1 Message Date
github-actions[bot] 987d56aaf3 chore: release 1.23.0 2026-10-16 15:55:04 +00:00
Charles Gauthereau 2d44333be7 Merge pull request #120 from Portabase/dev
Dev
2026-10-16 17:52:57 +02:00
Charles Gauthereau d278abe1b4 Merge pull request #119 from Portabase/feat/postgresql-exclude-tables-schemas
feat: postgresql-exclude-tables-schemas
2026-10-16 17:23:54 +02:00
Charles Gauthereau ba02799c78 fix: refactoring 2026-10-16 17:20:04 +02:00
Charles Gauthereau bd76ac365b feat(postgres): pass exclude_tables/exclude_schemas to pg_dump (#455) 2026-10-16 16:34:54 +02:00
Charles Gauthereau 9cda3cca61 feat(postgres): parse exclude_tables/exclude_schemas options into pg_dump args 2026-10-16 16:32:07 +02:00
6 changed files with 145 additions and 4 deletions
+1 -1
View File
@@ -27,5 +27,5 @@ keywords:
- self-hosted
- portabase
license: Apache-2.0
version: 1.22.0
version: 1.23.0
date-released: '2026-02-24'
Generated
+1 -1
View File
@@ -3642,7 +3642,7 @@ dependencies = [
[[package]]
name = "portabase-agent"
version = "1.22.0"
version = "1.23.0"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portabase-agent"
version = "1.22.0"
version = "1.23.0"
edition = "2024"
license = "Apache-2.0"
+1 -1
View File
@@ -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
+30
View File
@@ -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)
@@ -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")] {
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)
}
+111
View File
@@ -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();
@@ -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;