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
+9
View File
@@ -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"
}
]
}
+3
View File
@@ -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)),
+79
View File
@@ -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?
}
+53
View File
@@ -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
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod backup;
pub mod database;
pub mod restore;
+78
View File
@@ -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?
}
+27 -22
View File
@@ -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
View File
@@ -1,4 +1,5 @@
pub mod backup;
pub(crate) mod cluster;
pub(crate) mod connection;
pub mod database;
mod format;
+19 -6
View File
@@ -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")?,
};
+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;