fix: pgdump-cluster (#68)

* feat: add as_str/from_str to PostgresDumpFormat

* feat: resolve pg_dumpall/psql binary names

* feat: add include_globals field to database config

* feat: add pg_dumpall/psql globals dump and apply

* feat: add postgres backup bundle (manifest + build + resolve)

* feat: bundle globals into postgres backup when include_globals is set

* feat: replay globals before pg_restore when backup archive is a bundle

* refactor: bind FD restore tempdir guard once to clear unused warnings

* docs: demonstrate include_globals in sample databases.json

* chore: silence test-only re-export warning in non-test builds

* revert: remove include_globals feature, restore plain pg_dump/pg_restore

* feat: add pg_dumpall/psql binary names and is_superuser check

* feat: add postgresql-cluster db type and config parsing

* feat: pg_dumpall cluster backup and psql restore

* feat: route postgresql-cluster through PostgresClusterDatabase

* docs: add postgresql-cluster sample to databases.json

* refactor: split cluster mode into cluster/ module (backup, restore, database)

* test: mirror cluster tests into src/tests/domain/cluster/
This commit is contained in:
Charles GTE
2026-06-27 17:23:57 +02:00
committed by GitHub
parent 0b7b7ef59b
commit 01f5d34c96
17 changed files with 602 additions and 29 deletions
+53
View File
@@ -0,0 +1,53 @@
use super::{env_for, start_cluster};
use crate::domain::postgres::{cluster, connection};
use crate::services::backup::logger::JobLogger;
use crate::tests::init_tracing_for_test;
use std::sync::Arc;
use tempfile::TempDir;
#[tokio::test]
async fn produces_sql_with_roles_and_databases() {
init_tracing_for_test();
let (_c, cfg) = start_cluster("testuser").await;
let dir = TempDir::new().unwrap();
let logger = Arc::new(JobLogger::new());
let sql = cluster::backup::run(cfg.clone(), dir.path().to_path_buf(), env_for(&cfg), logger)
.await
.unwrap();
assert!(sql.is_file());
let contents = std::fs::read_to_string(&sql).unwrap();
assert!(contents.contains("CREATE ROLE"), "expected CREATE ROLE in dump");
assert!(
contents.contains("CREATE DATABASE") || contents.contains("\\connect"),
"expected database statements in dump"
);
}
#[tokio::test]
async fn requires_superuser() {
init_tracing_for_test();
let (_c, super_cfg) = start_cluster("testuser").await;
// Create a NON-superuser login role on the cluster.
let client = connection::connect(&super_cfg).await.unwrap();
client
.batch_execute("CREATE ROLE appuser LOGIN PASSWORD 'changeme' NOSUPERUSER;")
.await
.unwrap();
let mut weak = super_cfg.clone();
weak.username = "appuser".to_string();
let dir = TempDir::new().unwrap();
let logger = Arc::new(JobLogger::new());
let err = cluster::backup::run(weak.clone(), dir.path().to_path_buf(), env_for(&weak), logger)
.await
.unwrap_err();
assert!(
err.to_string().contains("superuser"),
"expected a superuser error, got: {err}"
);
}
+30
View File
@@ -0,0 +1,30 @@
use crate::domain::factory::DatabaseFactory;
use crate::services::config::{DatabaseConfig, DbType};
use std::path::Path;
fn cluster_config() -> DatabaseConfig {
DatabaseConfig {
name: "cluster".to_string(),
database: "postgres".to_string(),
db_type: DbType::PostgresqlCluster,
username: "postgres".to_string(),
password: "changeme".to_string(),
port: 5432,
host: "localhost".to_string(),
generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(),
path: String::new(),
max_packet_size: String::new(),
}
}
#[tokio::test]
async fn factory_routes_cluster_for_backup_with_sql_extension() {
let db = DatabaseFactory::create_for_backup(cluster_config()).await;
assert_eq!(db.file_extension(), ".sql");
}
#[tokio::test]
async fn factory_routes_cluster_for_restore_with_sql_extension() {
let db = DatabaseFactory::create_for_restore(cluster_config(), Path::new("dump.sql")).await;
assert_eq!(db.file_extension(), ".sql");
}
+47
View File
@@ -0,0 +1,47 @@
mod backup;
mod database;
mod restore;
use crate::services::config::{DatabaseConfig, DbType};
use std::collections::HashMap;
use testcontainers::runners::AsyncRunner;
use testcontainers::{ContainerAsync, ImageExt};
use testcontainers_modules::postgres::Postgres;
use url::Host;
async fn start_cluster(user: &str) -> (ContainerAsync<Postgres>, DatabaseConfig) {
let container = Postgres::default()
.with_env_var("POSTGRES_DB", "postgres")
.with_env_var("POSTGRES_USER", user)
.with_env_var("POSTGRES_PASSWORD", "changeme")
.with_tag("17")
.start()
.await
.unwrap();
let host = container
.get_host()
.await
.unwrap_or(Host::parse("127.0.0.1").unwrap());
let port = container.get_host_port_ipv4(5432).await.unwrap_or(5432);
let config = DatabaseConfig {
name: format!("cluster-{}", user),
database: "postgres".to_string(),
db_type: DbType::PostgresqlCluster,
username: user.to_string(),
password: "changeme".to_string(),
port,
host: host.to_string(),
generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(),
path: "".to_string(),
max_packet_size: "".to_string(),
};
(container, config)
}
fn env_for(cfg: &DatabaseConfig) -> HashMap<String, String> {
let mut env = std::env::vars().collect::<HashMap<_, _>>();
env.insert("PGPASSWORD".to_string(), cfg.password.clone());
env
}
+83
View File
@@ -0,0 +1,83 @@
use super::{env_for, start_cluster};
use crate::domain::postgres::{cluster, connection};
use crate::services::backup::logger::JobLogger;
use crate::tests::init_tracing_for_test;
use std::sync::Arc;
use tempfile::TempDir;
#[tokio::test]
async fn backup_restore_round_trip_preserves_ownership() {
init_tracing_for_test();
// Source cluster A: seed a role + a table owned by that role.
let (_a, src) = start_cluster("testuser").await;
let client = connection::connect(&src).await.unwrap();
client
.batch_execute(
"CREATE ROLE appowner LOGIN PASSWORD 'changeme' NOSUPERUSER;\n\
CREATE TABLE owned_tbl (id int);\n\
ALTER TABLE owned_tbl OWNER TO appowner;",
)
.await
.unwrap();
let dir = TempDir::new().unwrap();
let sql = cluster::backup::run(src.clone(), dir.path().to_path_buf(), env_for(&src), Arc::new(JobLogger::new()))
.await
.unwrap();
// Target cluster B: fresh, same bootstrap user.
let (_b, mut dst) = start_cluster("testuser").await;
cluster::restore::run(dst.clone(), sql.clone(), env_for(&dst), Arc::new(JobLogger::new()))
.await
.unwrap();
// Verify the seeded role exists and the table's owner was preserved on B.
dst.database = "postgres".to_string();
let bclient = connection::connect(&dst).await.unwrap();
let role_exists: bool = bclient
.query_one("SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'appowner');", &[])
.await
.unwrap()
.get(0);
assert!(role_exists, "appowner role must be recreated on the target");
let owner: String = bclient
.query_one(
"SELECT tableowner FROM pg_tables WHERE tablename = 'owned_tbl';",
&[],
)
.await
.unwrap()
.get(0);
assert_eq!(owner, "appowner", "table ownership must be preserved");
}
#[tokio::test]
async fn requires_superuser() {
init_tracing_for_test();
let (_c, super_cfg) = start_cluster("testuser").await;
// A non-superuser login role must be rejected before psql runs.
let client = connection::connect(&super_cfg).await.unwrap();
client
.batch_execute("CREATE ROLE appuser LOGIN PASSWORD 'changeme' NOSUPERUSER;")
.await
.unwrap();
let mut weak = super_cfg.clone();
weak.username = "appuser".to_string();
// The superuser pre-check happens before the dump file is read, so a
// non-existent restore path is fine — it must never be touched.
let missing = std::path::PathBuf::from("/nonexistent/cluster.sql");
let err = cluster::restore::run(weak.clone(), missing, env_for(&weak), Arc::new(JobLogger::new()))
.await
.unwrap_err();
assert!(
err.to_string().contains("superuser"),
"expected a superuser error, got: {err}"
);
}
+1
View File
@@ -2,6 +2,7 @@ mod mariadb;
mod mongodb;
mod mysql;
mod postgres;
mod cluster;
mod redis;
mod valkey;
mod firebird;
+36 -1
View File
@@ -56,6 +56,20 @@ async fn postgres_ping_test() {
assert_eq!(reachable, true);
}
#[tokio::test]
async fn is_superuser_detects_superuser_role() {
init_tracing_for_test();
// The testcontainer's POSTGRES_USER ("testuser") is the bootstrap superuser.
let (_container, config) = create_config().await;
let is_super = crate::domain::postgres::connection::is_superuser(&config)
.await
.unwrap();
assert!(is_super);
}
#[tokio::test]
async fn postgres_backup_restore_test() {
init_tracing_for_test();
@@ -152,7 +166,8 @@ async fn postgres_password_with_slash_test() {
mod select_pg_path_tests {
use crate::domain::postgres::connection::{
pg_dump_binary_name, pg_dump_exists_in, select_pg_path_with,
pg_dump_binary_name, pg_dump_exists_in, pg_dumpall_binary_name, psql_binary_name,
select_pg_path_with,
};
// `select_pg_path_with` takes the `PG_BIN_DIR` override as a plain
@@ -209,4 +224,24 @@ mod select_pg_path_tests {
let dir = std::path::Path::new("this/path/almost-certainly/does-not-exist-12345");
assert!(!pg_dump_exists_in(dir));
}
#[test]
fn pg_dumpall_binary_name_is_platform_specific() {
let name = pg_dumpall_binary_name();
if cfg!(target_os = "windows") {
assert_eq!(name, "pg_dumpall.exe");
} else {
assert_eq!(name, "pg_dumpall");
}
}
#[test]
fn psql_binary_name_is_platform_specific() {
let name = psql_binary_name();
if cfg!(target_os = "windows") {
assert_eq!(name, "psql.exe");
} else {
assert_eq!(name, "psql");
}
}
}
+79
View File
@@ -0,0 +1,79 @@
use crate::core::context::Context;
use crate::services::api::ApiClient;
use crate::services::config::ConfigService;
use crate::utils::edge_key::EdgeKey;
use std::io::Write;
use std::sync::Arc;
use tempfile::NamedTempFile;
// `ConfigService::load` never touches `self.ctx` on the `Some(file_path)` path,
// so the values here don't matter — but `Context::new()` panics without an
// `EDGE_KEY` env var, so build the struct directly (mirrors
// backup_uploader_tests.rs's `ctx_pointing_at`).
fn test_context() -> Arc<Context> {
Arc::new(Context {
edge_key: EdgeKey {
server_url: String::new(),
agent_id: "agent-1".to_string(),
master_key_b64: String::new(),
},
api: ApiClient::new(String::new()),
})
}
fn write_json(contents: &str) -> NamedTempFile {
let mut file = NamedTempFile::with_suffix(".json").unwrap();
file.write_all(contents.as_bytes()).unwrap();
file
}
#[test]
fn parses_postgresql_cluster_type() {
let file = write_json(
r#"{
"databases": [
{
"name": "cluster1",
"type": "postgresql-cluster",
"username": "postgres",
"password": "p",
"port": 5432,
"host": "localhost",
"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_eq!(cfg.databases[0].db_type.as_str(), "postgresql-cluster");
// `database` is optional for cluster entries and defaults to "postgres".
assert_eq!(cfg.databases[0].database, "postgres");
}
#[test]
fn postgresql_cluster_respects_explicit_database() {
let file = write_json(
r#"{
"databases": [
{
"name": "cluster1",
"type": "postgresql-cluster",
"database": "maintenance",
"username": "postgres",
"password": "p",
"port": 5432,
"host": "localhost",
"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_eq!(cfg.databases[0].database, "maintenance");
}
+1
View File
@@ -1,2 +1,3 @@
mod api_models_tests;
mod backup_uploader_tests;
mod config_tests;