use std::path::{
Path,
PathBuf,
};
use chrono::Utc;
use serde::{
Deserialize,
Serialize,
};
use sqlx::sqlite::SqliteConnectOptions;
use sqlx::{
Connection,
SqliteConnection,
};
use crate::domain::{
DomainError,
DomainResult,
};
use crate::infrastructure::config::{
PipedashConfig,
StorageBackend,
};
use crate::infrastructure::ConfigBackend;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupPaths {
pub source_db: Option<PathBuf>,
pub source_config: PathBuf,
pub target_db: Option<PathBuf>,
pub timestamp: String,
}
pub struct BackupManager {
data_dir: PathBuf,
}
impl BackupManager {
pub fn new(data_dir: PathBuf) -> Self {
Self { data_dir }
}
async fn backup_sqlite_database(src: &Path, dst: &Path) -> DomainResult<()> {
let destination = dst.to_str().ok_or_else(|| {
DomainError::DatabaseError(format!("Backup path is not valid UTF-8: {}", dst.display()))
})?;
let mut connection = SqliteConnection::connect_with(
&SqliteConnectOptions::new().filename(src).read_only(true),
)
.await
.map_err(|e| {
DomainError::DatabaseError(format!("Failed to open SQLite database for backup: {}", e))
})?;
sqlx::query("VACUUM INTO ?")
.bind(destination)
.execute(&mut connection)
.await
.map_err(|e| {
DomainError::DatabaseError(format!("Failed to backup SQLite database: {}", e))
})?;
connection.close().await.map_err(|e| {
DomainError::DatabaseError(format!("Failed to close SQLite backup connection: {}", e))
})
}
async fn restore_sqlite_database(backup: &Path, dst: &Path) -> DomainResult<()> {
let source = backup.to_str().ok_or_else(|| {
DomainError::DatabaseError(format!(
"Backup path is not valid UTF-8: {}",
backup.display()
))
})?;
let restore_error = |e: sqlx::Error| {
DomainError::DatabaseError(format!("Failed to restore SQLite database: {}", e))
};
let mut connection = SqliteConnection::connect_with(
&SqliteConnectOptions::new()
.filename(dst)
.foreign_keys(false),
)
.await
.map_err(restore_error)?;
sqlx::query("ATTACH DATABASE ? AS backup")
.bind(source)
.execute(&mut connection)
.await
.map_err(restore_error)?;
let tables: Vec<String> = sqlx::query_scalar(
"SELECT name FROM backup.sqlite_master WHERE type = 'table' \
AND (name NOT LIKE 'sqlite\\_%' ESCAPE '\\' OR name = 'sqlite_sequence')",
)
.fetch_all(&mut connection)
.await
.map_err(restore_error)?;
let mut transaction = connection.begin().await.map_err(restore_error)?;
for table in &tables {
let table = table.replace('"', "\"\"");
let sql = format!(
"DELETE FROM main.\"{0}\"; INSERT INTO main.\"{0}\" SELECT * FROM backup.\"{0}\"",
table
);
sqlx::raw_sql(sqlx::AssertSqlSafe(sql))
.execute(&mut *transaction)
.await
.map_err(restore_error)?;
}
transaction.commit().await.map_err(restore_error)?;
sqlx::query("DETACH DATABASE backup")
.execute(&mut connection)
.await
.map_err(restore_error)?;
connection.close().await.map_err(restore_error)
}
pub async fn create_backups(
&self, source_config: &PipedashConfig, target_config: &PipedashConfig,
source_backend: Option<&dyn ConfigBackend>,
) -> DomainResult<BackupPaths> {
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
tracing::info!("Creating migration backups with timestamp: {}", timestamp);
let source_db_backup = self
.backup_source_database(source_config, source_backend, ×tamp)
.await?;
let config_backup = self.backup_config_file(×tamp).await?;
let target_db_backup = self
.backup_target_database(target_config, ×tamp)
.await?;
let backups = BackupPaths {
source_db: source_db_backup,
source_config: config_backup,
target_db: target_db_backup,
timestamp,
};
tracing::info!("Backups created successfully: {:?}", backups);
Ok(backups)
}
async fn backup_source_database(
&self, source_config: &PipedashConfig, source_backend: Option<&dyn ConfigBackend>,
timestamp: &str,
) -> DomainResult<Option<PathBuf>> {
match source_config.storage.backend {
StorageBackend::Sqlite => {
let src = source_config.db_path();
if !src.exists() {
tracing::warn!("Source database does not exist: {:?}", src);
return Ok(None);
}
let dst = src.with_file_name(format!("pipedash.db.backup-{}", timestamp));
Self::backup_sqlite_database(&src, &dst).await?;
tracing::info!("SQLite database backed up to: {:?}", dst);
Ok(Some(dst))
}
StorageBackend::Postgres => {
if let Some(backend) = source_backend {
let export = backend.export_all().await?;
let backup_path = self
.data_dir
.join(format!("postgres_backup_{}.json", timestamp));
let json = serde_json::to_string_pretty(&export).map_err(|e| {
DomainError::DatabaseError(format!(
"Failed to serialize PostgreSQL backup: {}",
e
))
})?;
tokio::fs::write(&backup_path, json).await.map_err(|e| {
DomainError::DatabaseError(format!(
"Failed to write PostgreSQL backup: {}",
e
))
})?;
tracing::info!("PostgreSQL database exported to: {:?}", backup_path);
Ok(Some(backup_path))
} else {
tracing::warn!("No source backend provided for PostgreSQL backup");
Ok(None)
}
}
}
}
async fn backup_config_file(&self, timestamp: &str) -> DomainResult<PathBuf> {
use crate::infrastructure::config::ConfigLoader;
let config_path = ConfigLoader::discover_config_path();
if !config_path.exists() {
return Err(DomainError::InvalidConfig(format!(
"Config file not found: {:?}",
config_path
)));
}
let backup_path = config_path.with_file_name(format!("config.toml.backup-{}", timestamp));
tokio::fs::copy(&config_path, &backup_path)
.await
.map_err(|e| {
DomainError::DatabaseError(format!("Failed to backup config file: {}", e))
})?;
tracing::info!("Config file backed up to: {:?}", backup_path);
Ok(backup_path)
}
async fn backup_target_database(
&self, target_config: &PipedashConfig, timestamp: &str,
) -> DomainResult<Option<PathBuf>> {
match target_config.storage.backend {
StorageBackend::Sqlite => {
let target_path = target_config.db_path();
if !target_path.exists() {
return Ok(None);
}
let metadata = tokio::fs::metadata(&target_path).await.ok();
if let Some(meta) = metadata {
if meta.len() > 0 {
let backup_path = target_path
.with_file_name(format!("pipedash_target.db.backup-{}", timestamp));
Self::backup_sqlite_database(&target_path, &backup_path).await?;
tracing::info!("Target database backed up to: {:?}", backup_path);
return Ok(Some(backup_path));
}
}
Ok(None)
}
StorageBackend::Postgres => Ok(None),
}
}
pub async fn restore_backups(&self, backups: &BackupPaths) -> DomainResult<()> {
tracing::warn!("Migration failed - restoring from backups");
use crate::infrastructure::config::ConfigLoader;
let config_path = ConfigLoader::discover_config_path();
if backups.source_config.exists() {
tokio::fs::copy(&backups.source_config, &config_path)
.await
.map_err(|e| {
DomainError::DatabaseError(format!("Failed to restore config file: {}", e))
})?;
tracing::info!("Config file restored from: {:?}", backups.source_config);
}
if let Some(source_db) = &backups.source_db {
if source_db.exists() {
if source_db.extension().is_some_and(|ext| ext == "json") {
tracing::warn!(
"PostgreSQL source backup exists but cannot be automatically restored"
);
} else {
let original_path = source_db.parent().unwrap().join("pipedash.db");
Self::restore_sqlite_database(source_db, &original_path).await?;
tracing::info!("Source database restored from: {:?}", source_db);
}
}
}
if let Some(target_db) = &backups.target_db {
if target_db.exists() {
let original_path = target_db.parent().unwrap().join("pipedash.db");
Self::restore_sqlite_database(target_db, &original_path).await?;
tracing::info!("Target database restored from: {:?}", target_db);
}
}
tracing::info!("Backup restoration complete");
Ok(())
}
pub async fn cleanup_backups(&self, backups: &BackupPaths, keep: bool) -> DomainResult<()> {
if keep {
tracing::info!("Keeping backups as requested");
return Ok(());
}
tracing::info!("Cleaning up migration backups");
if let Some(source_db) = &backups.source_db {
if source_db.exists() {
tokio::fs::remove_file(source_db).await.ok();
tracing::debug!("Deleted source database backup: {:?}", source_db);
}
}
if backups.source_config.exists() {
tokio::fs::remove_file(&backups.source_config).await.ok();
tracing::debug!("Deleted config backup: {:?}", backups.source_config);
}
if let Some(target_db) = &backups.target_db {
if target_db.exists() {
tokio::fs::remove_file(target_db).await.ok();
tracing::debug!("Deleted target database backup: {:?}", target_db);
}
}
self.cleanup_old_backups().await?;
Ok(())
}
async fn cleanup_old_backups(&self) -> DomainResult<()> {
use std::time::{
Duration,
SystemTime,
};
let retention_days = 7;
let cutoff = SystemTime::now() - Duration::from_secs(retention_days * 24 * 3600);
let patterns = vec!["*.backup-*", "*_backup_*.json"];
for pattern in patterns {
let glob_pattern = self.data_dir.join(pattern);
if let Ok(entries) = glob::glob(glob_pattern.to_str().unwrap()) {
for entry in entries.flatten() {
if let Ok(metadata) = tokio::fs::metadata(&entry).await {
if let Ok(modified) = metadata.modified() {
if modified < cutoff {
tokio::fs::remove_file(&entry).await.ok();
tracing::debug!("Deleted old backup: {:?}", entry);
}
}
}
}
}
}
Ok(())
}
pub async fn estimate_backup_size(&self, config: &PipedashConfig) -> u64 {
let mut total_size = 0u64;
let db_path = config.db_path();
if let Ok(metadata) = tokio::fs::metadata(&db_path).await {
total_size += metadata.len();
}
use crate::infrastructure::config::ConfigLoader;
let config_path = ConfigLoader::discover_config_path();
if let Ok(metadata) = tokio::fs::metadata(&config_path).await {
total_size += metadata.len();
}
total_size
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn check_wal_backup(target: bool) {
let dir = tempfile::tempdir().unwrap();
let mut config = PipedashConfig::default();
config.storage.data_dir = dir.path().to_str().unwrap().to_owned();
let pool = crate::infrastructure::database::init_database(config.db_path())
.await
.unwrap();
let mut writer = pool.acquire().await.unwrap();
sqlx::query("PRAGMA wal_autocheckpoint = 0")
.execute(&mut *writer)
.await
.unwrap();
sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
.execute(&mut *writer)
.await
.unwrap();
sqlx::query(
"INSERT INTO providers (id, name, provider_type, token_encrypted) VALUES (42, 'committed', 'jenkins', '')",
)
.execute(&mut *writer)
.await
.unwrap();
let mut transaction = writer.begin().await.unwrap();
sqlx::query(
"INSERT INTO providers (name, provider_type, token_encrypted) VALUES ('uncommitted', 'jenkins', '')",
)
.execute(&mut *transaction)
.await
.unwrap();
let manager = BackupManager::new(dir.path().to_path_buf());
let backup = if target {
manager.backup_target_database(&config, "wal").await
} else {
manager.backup_source_database(&config, None, "wal").await
}
.unwrap()
.unwrap();
let standalone = tempfile::tempdir().unwrap();
let snapshot_path = standalone.path().join("snapshot.db");
tokio::fs::copy(backup, &snapshot_path).await.unwrap();
let mut snapshot = SqliteConnection::connect_with(
&SqliteConnectOptions::new()
.filename(snapshot_path)
.read_only(true),
)
.await
.unwrap();
let providers: Vec<(i64, String)> =
sqlx::query_as("SELECT id, name FROM providers ORDER BY id")
.fetch_all(&mut snapshot)
.await
.unwrap();
assert_eq!(providers, vec![(42, "committed".to_owned())]);
let integrity: String = sqlx::query_scalar("PRAGMA integrity_check")
.fetch_one(&mut snapshot)
.await
.unwrap();
assert_eq!(integrity, "ok");
snapshot.close().await.unwrap();
transaction.rollback().await.unwrap();
drop(writer);
pool.close().await;
}
#[tokio::test]
async fn source_backup_includes_committed_wal_providers() {
check_wal_backup(false).await;
}
#[tokio::test]
async fn target_backup_includes_committed_wal_providers() {
check_wal_backup(true).await;
}
#[tokio::test]
async fn restore_keeps_open_wal_source_consistent() {
let dir = tempfile::tempdir().unwrap();
let mut config = PipedashConfig::default();
config.storage.data_dir = dir.path().to_str().unwrap().to_owned();
let pool = crate::infrastructure::database::init_database(config.db_path())
.await
.unwrap();
let mut writer = pool.acquire().await.unwrap();
for statement in [
"PRAGMA wal_autocheckpoint = 0",
"WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 200) \
INSERT INTO providers (name, provider_type, token_encrypted) \
SELECT 'bulk' || i, 'jenkins', hex(randomblob(1000)) FROM n",
"PRAGMA wal_checkpoint(TRUNCATE)",
"DELETE FROM providers",
"INSERT INTO providers (id, name, provider_type, token_encrypted) VALUES (42, 'kept', 'jenkins', '')",
] {
sqlx::query(statement).execute(&mut *writer).await.unwrap();
}
let manager = BackupManager::new(dir.path().to_path_buf());
let source_db = manager
.backup_source_database(&config, None, "restore")
.await
.unwrap();
sqlx::query("INSERT INTO providers (name, provider_type, token_encrypted) VALUES ('failed', 'jenkins', '')")
.execute(&mut *writer)
.await
.unwrap();
let backups = BackupPaths {
source_db,
source_config: dir.path().join("missing-config.toml.backup"),
target_db: None,
timestamp: "restore".into(),
};
manager.restore_backups(&backups).await.unwrap();
let integrity: String = sqlx::query_scalar("PRAGMA integrity_check")
.fetch_one(&mut *writer)
.await
.unwrap();
assert_eq!(integrity, "ok");
let providers: Vec<(i64, String)> =
sqlx::query_as("SELECT id, name FROM providers ORDER BY id")
.fetch_all(&mut *writer)
.await
.unwrap();
assert_eq!(providers, vec![(42, "kept".to_owned())]);
drop(writer);
pool.close().await;
}
async fn check_target_restore(postgres_source: bool) {
let source_dir = tempfile::tempdir().unwrap();
let target_dir = tempfile::tempdir().unwrap();
let mut target_config = PipedashConfig::default();
target_config.storage.data_dir = target_dir.path().to_str().unwrap().to_owned();
let pool = crate::infrastructure::database::init_database(target_config.db_path())
.await
.unwrap();
sqlx::query("INSERT INTO providers (id, name, provider_type, token_encrypted) VALUES (7, 'original', 'jenkins', '')")
.execute(&pool)
.await
.unwrap();
let manager = BackupManager::new(source_dir.path().to_path_buf());
let target_db = manager
.backup_target_database(&target_config, "target")
.await
.unwrap();
sqlx::query("INSERT INTO providers (name, provider_type, token_encrypted) VALUES ('first', 'jenkins', '')")
.execute(&pool)
.await
.unwrap();
let source_db = if postgres_source {
let json_backup = source_dir.path().join("postgres_backup_target.json");
tokio::fs::write(&json_backup, "{\"providers\": []}")
.await
.unwrap();
Some(json_backup)
} else {
None
};
let backups = BackupPaths {
source_db,
source_config: source_dir.path().join("missing-config.toml.backup"),
target_db,
timestamp: "target".into(),
};
manager.restore_backups(&backups).await.unwrap();
let providers: Vec<(i64, String)> =
sqlx::query_as("SELECT id, name FROM providers ORDER BY id")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(providers, vec![(7, "original".to_owned())]);
pool.close().await;
}
#[tokio::test]
async fn restore_drops_committed_target_writes() {
check_target_restore(false).await;
}
#[tokio::test]
async fn restore_drops_committed_target_writes_after_postgres_source() {
check_target_restore(true).await;
}
#[tokio::test]
async fn restore_keeps_sqlite_file_after_postgres_backup() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::env::set_var("PIPEDASH_CONFIG_PATH", &config_path);
tokio::fs::write(&config_path, "[storage]\nbackend = \"postgres\"\n")
.await
.unwrap();
let config_backup = dir.path().join("config.toml.backup-x");
tokio::fs::copy(&config_path, &config_backup).await.unwrap();
let sqlite_path = dir.path().join("pipedash.db");
tokio::fs::write(&sqlite_path, b"SQLite format 3\0")
.await
.unwrap();
let json_backup = dir.path().join("postgres_backup_x.json");
tokio::fs::write(&json_backup, "{\"providers\": []}")
.await
.unwrap();
let backups = BackupPaths {
source_db: Some(json_backup),
source_config: config_backup,
target_db: None,
timestamp: "x".into(),
};
BackupManager::new(dir.path().to_path_buf())
.restore_backups(&backups)
.await
.unwrap();
let after = tokio::fs::read(&sqlite_path).await.unwrap();
assert_eq!(
after,
b"SQLite format 3\0",
"{}",
String::from_utf8_lossy(&after)
);
}
}