mirror of
https://github.com/Portabase/agent.git
synced 2026-09-10 01:57:10 +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:
@@ -111,6 +111,15 @@
|
||||
"port": 1433,
|
||||
"host": "db-mssql",
|
||||
"generated_id": "16706125-ff7e-4c97-8c83-0adeff214682"
|
||||
},
|
||||
{
|
||||
"name": "Test database - PostgreSQL cluster",
|
||||
"type": "postgresql-cluster",
|
||||
"username": "nextclouddbuser",
|
||||
"password": "50AL2Oh5IXajbOAxfJ",
|
||||
"port": 5432,
|
||||
"host": "nextcloud-db",
|
||||
"generated_id": "16678199-ff7e-4c97-8c83-0adeff214681"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::domain::mongodb::database::MongoDatabase;
|
||||
use crate::domain::mysql::database::MySQLDatabase;
|
||||
use crate::domain::postgres::cluster::database::PostgresClusterDatabase;
|
||||
use crate::domain::postgres::database::PostgresDatabase;
|
||||
use crate::domain::postgres::{detect_format_from_file, detect_format_from_size};
|
||||
use crate::domain::redis::database::RedisDatabase;
|
||||
@@ -31,6 +32,7 @@ impl DatabaseFactory {
|
||||
let format = detect_format_from_size(&cfg).await;
|
||||
Arc::new(PostgresDatabase::new(cfg, format))
|
||||
}
|
||||
DbType::PostgresqlCluster => Arc::new(PostgresClusterDatabase::new(cfg)),
|
||||
DbType::Mysql => Arc::new(MySQLDatabase::new(cfg)),
|
||||
DbType::Mariadb => Arc::new(MariaDBDatabase::new(cfg)),
|
||||
DbType::MongoDB => Arc::new(MongoDatabase::new(cfg)),
|
||||
@@ -48,6 +50,7 @@ impl DatabaseFactory {
|
||||
let format = detect_format_from_file(restore_file);
|
||||
Arc::new(PostgresDatabase::new(cfg, format))
|
||||
}
|
||||
DbType::PostgresqlCluster => Arc::new(PostgresClusterDatabase::new(cfg)),
|
||||
DbType::Mysql => Arc::new(MySQLDatabase::new(cfg)),
|
||||
DbType::Mariadb => Arc::new(MariaDBDatabase::new(cfg)),
|
||||
DbType::MongoDB => Arc::new(MongoDatabase::new(cfg)),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::super::connection::{
|
||||
is_superuser, pg_dumpall_binary_name, select_pg_path, server_version,
|
||||
};
|
||||
use crate::services::backup::logger::JobLogger;
|
||||
use crate::services::config::DatabaseConfig;
|
||||
|
||||
pub async fn run(
|
||||
cfg: DatabaseConfig,
|
||||
backup_dir: PathBuf,
|
||||
env: HashMap<String, String>,
|
||||
logger: Arc<JobLogger>,
|
||||
) -> Result<PathBuf> {
|
||||
tokio::task::spawn_blocking(move || -> Result<PathBuf> {
|
||||
logger.log("info", format!("Starting cluster backup for {}", cfg.name));
|
||||
|
||||
let version = match futures::executor::block_on(server_version(&cfg)) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
logger.log("error", format!("Failed to get server version for {}: {:?}", cfg.name, e));
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
match futures::executor::block_on(is_superuser(&cfg)) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
logger.log("error", format!("postgresql-cluster backup requires a superuser role for {}", cfg.name));
|
||||
anyhow::bail!("postgresql-cluster backup requires a superuser role for {}", cfg.name);
|
||||
}
|
||||
Err(e) => {
|
||||
logger.log("error", format!("Failed to check superuser status for {}: {:?}", cfg.name, e));
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
let pg_dumpall = select_pg_path(&version).join(pg_dumpall_binary_name());
|
||||
let file_path = backup_dir.join(format!("{}.sql", cfg.generated_id));
|
||||
|
||||
logger.log("info", format!("Running pg_dumpall for cluster {} via {:?}", cfg.name, pg_dumpall));
|
||||
|
||||
let start = Instant::now();
|
||||
let output = Command::new(&pg_dumpall)
|
||||
.arg("--host").arg(&cfg.host)
|
||||
.arg("--port").arg(cfg.port.to_string())
|
||||
.arg("--username").arg(&cfg.username)
|
||||
.arg("-v")
|
||||
.arg("-f").arg(&file_path)
|
||||
.envs(env)
|
||||
.output();
|
||||
let duration_ms = start.elapsed().as_millis() as f64;
|
||||
|
||||
match output {
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
|
||||
let exit_code = o.status.code().unwrap_or(-1);
|
||||
if o.status.success() {
|
||||
logger.log_command("pg_dumpall", if stderr.is_empty() { None } else { Some(stderr) }, Some(0), Some(duration_ms));
|
||||
logger.log("info", format!("Cluster backup completed for {} at {:?}", cfg.name, file_path));
|
||||
Ok(file_path)
|
||||
} else {
|
||||
logger.log_command("pg_dumpall", Some(stderr), Some(exit_code), Some(duration_ms));
|
||||
anyhow::bail!("Cluster backup (pg_dumpall) failed for {}", cfg.name);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
logger.log_command("pg_dumpall", Some(e.to_string()), Some(-1), Some(duration_ms));
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::super::ping;
|
||||
use super::{backup, restore};
|
||||
use crate::domain::factory::Database;
|
||||
use crate::services::backup::logger::JobLogger;
|
||||
use crate::services::config::DatabaseConfig;
|
||||
use crate::utils::locks::{DbOpLock, FileLock};
|
||||
|
||||
pub struct PostgresClusterDatabase {
|
||||
pub cfg: DatabaseConfig,
|
||||
}
|
||||
|
||||
impl PostgresClusterDatabase {
|
||||
pub fn new(cfg: DatabaseConfig) -> Self {
|
||||
Self { cfg }
|
||||
}
|
||||
|
||||
fn build_env(&self) -> HashMap<String, String> {
|
||||
let mut envs = std::env::vars().collect::<HashMap<_, _>>();
|
||||
envs.insert("PGPASSWORD".to_string(), self.cfg.password.to_string());
|
||||
envs
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Database for PostgresClusterDatabase {
|
||||
fn file_extension(&self) -> &'static str {
|
||||
".sql"
|
||||
}
|
||||
|
||||
async fn ping(&self) -> Result<bool> {
|
||||
ping::run(self.cfg.clone()).await
|
||||
}
|
||||
|
||||
async fn backup(&self, dir: &Path, logger: Arc<JobLogger>) -> Result<PathBuf> {
|
||||
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Backup.as_str()).await?;
|
||||
let res = backup::run(self.cfg.clone(), dir.to_path_buf(), self.build_env(), logger).await;
|
||||
FileLock::release(&self.cfg.generated_id).await?;
|
||||
res
|
||||
}
|
||||
|
||||
async fn restore(&self, file: &Path, logger: Arc<JobLogger>) -> Result<()> {
|
||||
FileLock::acquire(&self.cfg.generated_id, DbOpLock::Restore.as_str()).await?;
|
||||
let res = restore::run(self.cfg.clone(), file.to_path_buf(), self.build_env(), logger).await;
|
||||
FileLock::release(&self.cfg.generated_id).await?;
|
||||
res
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod backup;
|
||||
pub mod database;
|
||||
pub mod restore;
|
||||
@@ -0,0 +1,78 @@
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::super::connection::{is_superuser, psql_binary_name, select_pg_path, server_version};
|
||||
use crate::services::backup::logger::JobLogger;
|
||||
use crate::services::config::DatabaseConfig;
|
||||
|
||||
pub async fn run(
|
||||
cfg: DatabaseConfig,
|
||||
restore_file: PathBuf,
|
||||
env: HashMap<String, String>,
|
||||
logger: Arc<JobLogger>,
|
||||
) -> Result<()> {
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
logger.log("info", format!("Starting cluster restore for {}", cfg.name));
|
||||
|
||||
let version = match futures::executor::block_on(server_version(&cfg)) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
logger.log("error", format!("Failed to get server version for {}: {:?}", cfg.name, e));
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
match futures::executor::block_on(is_superuser(&cfg)) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
logger.log("error", format!("postgresql-cluster restore requires a superuser role for {}", cfg.name));
|
||||
anyhow::bail!("postgresql-cluster restore requires a superuser role for {}", cfg.name);
|
||||
}
|
||||
Err(e) => {
|
||||
logger.log("error", format!("Failed to check superuser status for {}: {:?}", cfg.name, e));
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
let psql = select_pg_path(&version).join(psql_binary_name());
|
||||
|
||||
logger.log("info", format!("Replaying cluster dump for {} via {:?}", cfg.name, psql));
|
||||
|
||||
let start = Instant::now();
|
||||
let output = Command::new(&psql)
|
||||
.arg("--host").arg(&cfg.host)
|
||||
.arg("--port").arg(cfg.port.to_string())
|
||||
.arg("--username").arg(&cfg.username)
|
||||
.arg("--dbname").arg("postgres")
|
||||
.arg("-f").arg(&restore_file)
|
||||
.envs(env)
|
||||
.output();
|
||||
let duration_ms = start.elapsed().as_millis() as f64;
|
||||
|
||||
match output {
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
|
||||
let stdout = String::from_utf8_lossy(&o.stdout).to_string();
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
let exit_code = o.status.code().unwrap_or(-1);
|
||||
if o.status.success() {
|
||||
logger.log_command("psql", if combined.is_empty() { None } else { Some(combined) }, Some(0), Some(duration_ms));
|
||||
logger.log("info", format!("Cluster restore completed for {}", cfg.name));
|
||||
Ok(())
|
||||
} else {
|
||||
logger.log_command("psql", if combined.is_empty() { None } else { Some(combined) }, Some(exit_code), Some(duration_ms));
|
||||
anyhow::bail!("Cluster restore (psql) failed for {}", cfg.name);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
logger.log_command("psql", Some(e.to_string()), Some(-1), Some(duration_ms));
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
@@ -33,32 +33,21 @@ pub async fn server_version(cfg: &DatabaseConfig) -> Result<String> {
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// Resolves the `bin` directory of a PostgreSQL installation for the given
|
||||
/// major version, in a cross-platform way.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. The `PG_BIN_DIR` environment variable, if set, is used as-is. This
|
||||
/// allows users/CI to override detection for non-standard installs
|
||||
/// (e.g. portable PostgreSQL distributions, custom install locations).
|
||||
/// 2. Platform-specific default install locations (Debian/Ubuntu packages,
|
||||
/// the official Windows installer, Homebrew/Postgres.app on macOS, and
|
||||
/// common RPM-based layouts on other Linux distros).
|
||||
/// 3. A `PATH` lookup for `pg_dump` (`pg_dump.exe` on Windows), returning
|
||||
/// its parent directory.
|
||||
/// 4. The historical Debian/Ubuntu path as a last-resort fallback, so the
|
||||
/// function keeps returning a `PathBuf` (never panics) even when nothing
|
||||
/// was found, preserving the previous behavior for callers.
|
||||
///
|
||||
/// The override is sourced from `CONFIG.pg_bin_dir` (the `PG_BIN_DIR`
|
||||
/// environment variable). An empty value means "unset" and falls through to
|
||||
/// detection.
|
||||
pub async fn is_superuser(cfg: &DatabaseConfig) -> Result<bool> {
|
||||
let client = connect(cfg).await?;
|
||||
let is_super: bool = client
|
||||
.query_one("SELECT current_setting('is_superuser') = 'on';", &[])
|
||||
.await?
|
||||
.get(0);
|
||||
|
||||
Ok(is_super)
|
||||
}
|
||||
|
||||
|
||||
pub fn select_pg_path(version: &str) -> std::path::PathBuf {
|
||||
select_pg_path_with(version, &CONFIG.pg_bin_dir)
|
||||
}
|
||||
|
||||
/// Inner resolver behind [`select_pg_path`], parameterized over the
|
||||
/// `PG_BIN_DIR` override. Kept pure (no env / no `CONFIG` access) so it is
|
||||
/// unit-testable without mutating process-global state.
|
||||
pub(crate) fn select_pg_path_with(version: &str, pg_bin_dir: &str) -> std::path::PathBuf {
|
||||
let major = version.split('.').next().unwrap_or("17");
|
||||
|
||||
@@ -109,6 +98,22 @@ pub(crate) fn pg_dump_binary_name() -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pg_dumpall_binary_name() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"pg_dumpall.exe"
|
||||
} else {
|
||||
"pg_dumpall"
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn psql_binary_name() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"psql.exe"
|
||||
} else {
|
||||
"psql"
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pg_dump_exists_in(dir: &std::path::Path) -> bool {
|
||||
dir.join(pg_dump_binary_name()).is_file()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod backup;
|
||||
pub(crate) mod cluster;
|
||||
pub(crate) mod connection;
|
||||
pub mod database;
|
||||
mod format;
|
||||
|
||||
+19
-6
@@ -17,6 +17,8 @@ pub enum DbType {
|
||||
Mysql,
|
||||
Mariadb,
|
||||
Postgresql,
|
||||
#[serde(rename = "postgresql-cluster")]
|
||||
PostgresqlCluster,
|
||||
MongoDB,
|
||||
Sqlite,
|
||||
Redis,
|
||||
@@ -31,6 +33,7 @@ impl DbType {
|
||||
DbType::Mysql => "mysql",
|
||||
DbType::Mariadb => "mariadb",
|
||||
DbType::Postgresql => "postgresql",
|
||||
DbType::PostgresqlCluster => "postgresql-cluster",
|
||||
DbType::MongoDB => "mongodb",
|
||||
DbType::Sqlite => "sqlite",
|
||||
DbType::Redis => "redis",
|
||||
@@ -169,21 +172,26 @@ impl ConfigService {
|
||||
}
|
||||
|
||||
let username = match db.db_type {
|
||||
DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::Mssql => {
|
||||
required(&db.username, &db.name, "username")?
|
||||
}
|
||||
DbType::Postgresql
|
||||
| DbType::PostgresqlCluster
|
||||
| DbType::Mysql
|
||||
| DbType::Mariadb
|
||||
| DbType::Mssql => required(&db.username, &db.name, "username")?,
|
||||
_ => optional(&db.username),
|
||||
};
|
||||
|
||||
let password = match db.db_type {
|
||||
DbType::Postgresql | DbType::Mysql | DbType::Mariadb | DbType::Mssql => {
|
||||
required(&db.password, &db.name, "password")?
|
||||
}
|
||||
DbType::Postgresql
|
||||
| DbType::PostgresqlCluster
|
||||
| DbType::Mysql
|
||||
| DbType::Mariadb
|
||||
| DbType::Mssql => required(&db.password, &db.name, "password")?,
|
||||
_ => optional(&db.password),
|
||||
};
|
||||
|
||||
let host = match db.db_type {
|
||||
DbType::Postgresql
|
||||
| DbType::PostgresqlCluster
|
||||
| DbType::Mysql
|
||||
| DbType::Mariadb
|
||||
| DbType::MongoDB
|
||||
@@ -196,6 +204,7 @@ impl ConfigService {
|
||||
|
||||
let port = match db.db_type {
|
||||
DbType::Postgresql
|
||||
| DbType::PostgresqlCluster
|
||||
| DbType::Mysql
|
||||
| DbType::Mariadb
|
||||
| DbType::MongoDB
|
||||
@@ -208,6 +217,10 @@ impl ConfigService {
|
||||
|
||||
let database_name = match db.db_type {
|
||||
DbType::Sqlite | DbType::Redis | DbType::Valkey => optional(&db.database),
|
||||
DbType::PostgresqlCluster => db
|
||||
.database
|
||||
.clone()
|
||||
.unwrap_or_else(|| "postgres".to_string()),
|
||||
_ => required(&db.database, &db.name, "database")?,
|
||||
};
|
||||
|
||||
|
||||
@@ -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