Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
31009a8
feat: add as_str/from_str to PostgresDumpFormat
RambokDev Jun 26, 2026
7fab28a
feat: resolve pg_dumpall/psql binary names
RambokDev Jun 26, 2026
3ff03d7
feat: add include_globals field to database config
RambokDev Jun 26, 2026
7261901
feat: add pg_dumpall/psql globals dump and apply
RambokDev Jun 26, 2026
7262a9a
feat: add postgres backup bundle (manifest + build + resolve)
RambokDev Jun 26, 2026
a336cbb
feat: bundle globals into postgres backup when include_globals is set
RambokDev Jun 26, 2026
b7ec9a3
feat: replay globals before pg_restore when backup archive is a bundle
RambokDev Jun 26, 2026
26d4dc0
refactor: bind FD restore tempdir guard once to clear unused warnings
RambokDev Jun 26, 2026
5220c48
docs: demonstrate include_globals in sample databases.json
RambokDev Jun 26, 2026
ec81a9d
chore: silence test-only re-export warning in non-test builds
RambokDev Jun 26, 2026
5fa44ee
revert: remove include_globals feature, restore plain pg_dump/pg_restore
RambokDev Jun 26, 2026
5460c82
feat: add pg_dumpall/psql binary names and is_superuser check
RambokDev Jun 26, 2026
e5d1df4
feat: add postgresql-cluster db type and config parsing
RambokDev Jun 26, 2026
d530957
feat: pg_dumpall cluster backup and psql restore
RambokDev Jun 26, 2026
d488f8f
feat: route postgresql-cluster through PostgresClusterDatabase
RambokDev Jun 26, 2026
c7bdfe6
docs: add postgresql-cluster sample to databases.json
RambokDev Jun 26, 2026
dae2e45
refactor: split cluster mode into cluster/ module (backup, restore, d…
RambokDev Jun 26, 2026
0c6e3e3
test: mirror cluster tests into src/tests/domain/cluster/
RambokDev Jun 26, 2026
c3f7e7b
fix
RambokDev Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions databases.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 3 additions & 0 deletions src/domain/factory.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)),
Expand All @@ -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)),
Expand Down
79 changes: 79 additions & 0 deletions src/domain/postgres/cluster/backup.rs
Original file line number Diff line number Diff line change
@@ -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 changes: 53 additions & 0 deletions src/domain/postgres/cluster/database.rs
Original file line number Diff line number Diff line change
@@ -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 changes: 3 additions & 0 deletions src/domain/postgres/cluster/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod backup;
pub mod database;
pub mod restore;
78 changes: 78 additions & 0 deletions src/domain/postgres/cluster/restore.rs
Original file line number Diff line number Diff line change
@@ -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?
}
49 changes: 27 additions & 22 deletions src/domain/postgres/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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()
}
Expand Down
1 change: 1 addition & 0 deletions src/domain/postgres/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod backup;
pub(crate) mod cluster;
pub(crate) mod connection;
pub mod database;
mod format;
Expand Down
25 changes: 19 additions & 6 deletions src/services/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ pub enum DbType {
Mysql,
Mariadb,
Postgresql,
#[serde(rename = "postgresql-cluster")]
PostgresqlCluster,
MongoDB,
Sqlite,
Redis,
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -196,6 +204,7 @@ impl ConfigService {

let port = match db.db_type {
DbType::Postgresql
| DbType::PostgresqlCluster
| DbType::Mysql
| DbType::Mariadb
| DbType::MongoDB
Expand All @@ -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")?,
};

Expand Down
Loading