mirror of
https://github.com/Portabase/agent.git
synced 2026-10-12 15:59:05 +00:00
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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod mariadb;
|
||||
mod mongodb;
|
||||
mod mysql;
|
||||
mod postgres;
|
||||
mod cluster;
|
||||
mod redis;
|
||||
mod valkey;
|
||||
mod firebird;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
mod api_models_tests;
|
||||
mod backup_uploader_tests;
|
||||
mod config_tests;
|
||||
|
||||
Reference in New Issue
Block a user