pipedash_core/infrastructure/migration/
backup.rs1use std::path::{
2 Path,
3 PathBuf,
4};
5
6use chrono::Utc;
7use serde::{
8 Deserialize,
9 Serialize,
10};
11use sqlx::sqlite::SqliteConnectOptions;
12use sqlx::{
13 Connection,
14 SqliteConnection,
15};
16
17use crate::domain::{
18 DomainError,
19 DomainResult,
20};
21use crate::infrastructure::config::{
22 PipedashConfig,
23 StorageBackend,
24};
25use crate::infrastructure::ConfigBackend;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct BackupPaths {
29 pub source_db: Option<PathBuf>,
30 pub source_config: PathBuf,
31 pub target_db: Option<PathBuf>,
32 pub timestamp: String,
33}
34
35pub struct BackupManager {
36 data_dir: PathBuf,
37}
38
39impl BackupManager {
40 pub fn new(data_dir: PathBuf) -> Self {
41 Self { data_dir }
42 }
43
44 async fn backup_sqlite_database(src: &Path, dst: &Path) -> DomainResult<()> {
45 let destination = dst.to_str().ok_or_else(|| {
46 DomainError::DatabaseError(format!("Backup path is not valid UTF-8: {}", dst.display()))
47 })?;
48 let mut connection = SqliteConnection::connect_with(
49 &SqliteConnectOptions::new().filename(src).read_only(true),
50 )
51 .await
52 .map_err(|e| {
53 DomainError::DatabaseError(format!("Failed to open SQLite database for backup: {}", e))
54 })?;
55
56 sqlx::query("VACUUM INTO ?")
57 .bind(destination)
58 .execute(&mut connection)
59 .await
60 .map_err(|e| {
61 DomainError::DatabaseError(format!("Failed to backup SQLite database: {}", e))
62 })?;
63
64 connection.close().await.map_err(|e| {
65 DomainError::DatabaseError(format!("Failed to close SQLite backup connection: {}", e))
66 })
67 }
68
69 async fn restore_sqlite_database(backup: &Path, dst: &Path) -> DomainResult<()> {
70 let source = backup.to_str().ok_or_else(|| {
71 DomainError::DatabaseError(format!(
72 "Backup path is not valid UTF-8: {}",
73 backup.display()
74 ))
75 })?;
76 let restore_error = |e: sqlx::Error| {
77 DomainError::DatabaseError(format!("Failed to restore source database: {}", e))
78 };
79 let mut connection = SqliteConnection::connect_with(
80 &SqliteConnectOptions::new()
81 .filename(dst)
82 .foreign_keys(false),
83 )
84 .await
85 .map_err(restore_error)?;
86
87 sqlx::query("ATTACH DATABASE ? AS backup")
88 .bind(source)
89 .execute(&mut connection)
90 .await
91 .map_err(restore_error)?;
92 let tables: Vec<String> = sqlx::query_scalar(
93 "SELECT name FROM backup.sqlite_master WHERE type = 'table' \
94 AND (name NOT LIKE 'sqlite\\_%' ESCAPE '\\' OR name = 'sqlite_sequence')",
95 )
96 .fetch_all(&mut connection)
97 .await
98 .map_err(restore_error)?;
99
100 let mut transaction = connection.begin().await.map_err(restore_error)?;
101 for table in &tables {
102 let table = table.replace('"', "\"\"");
103 let sql = format!(
104 "DELETE FROM main.\"{0}\"; INSERT INTO main.\"{0}\" SELECT * FROM backup.\"{0}\"",
105 table
106 );
107 sqlx::raw_sql(sqlx::AssertSqlSafe(sql))
108 .execute(&mut *transaction)
109 .await
110 .map_err(restore_error)?;
111 }
112 transaction.commit().await.map_err(restore_error)?;
113
114 sqlx::query("DETACH DATABASE backup")
115 .execute(&mut connection)
116 .await
117 .map_err(restore_error)?;
118 connection.close().await.map_err(restore_error)
119 }
120
121 pub async fn create_backups(
122 &self, source_config: &PipedashConfig, target_config: &PipedashConfig,
123 source_backend: Option<&dyn ConfigBackend>,
124 ) -> DomainResult<BackupPaths> {
125 let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
126
127 tracing::info!("Creating migration backups with timestamp: {}", timestamp);
128
129 let source_db_backup = self
130 .backup_source_database(source_config, source_backend, ×tamp)
131 .await?;
132
133 let config_backup = self.backup_config_file(×tamp).await?;
134
135 let target_db_backup = self
136 .backup_target_database(target_config, ×tamp)
137 .await?;
138
139 let backups = BackupPaths {
140 source_db: source_db_backup,
141 source_config: config_backup,
142 target_db: target_db_backup,
143 timestamp,
144 };
145
146 tracing::info!("Backups created successfully: {:?}", backups);
147
148 Ok(backups)
149 }
150
151 async fn backup_source_database(
152 &self, source_config: &PipedashConfig, source_backend: Option<&dyn ConfigBackend>,
153 timestamp: &str,
154 ) -> DomainResult<Option<PathBuf>> {
155 match source_config.storage.backend {
156 StorageBackend::Sqlite => {
157 let src = source_config.db_path();
158 if !src.exists() {
159 tracing::warn!("Source database does not exist: {:?}", src);
160 return Ok(None);
161 }
162
163 let dst = src.with_file_name(format!("pipedash.db.backup-{}", timestamp));
164 Self::backup_sqlite_database(&src, &dst).await?;
165
166 tracing::info!("SQLite database backed up to: {:?}", dst);
167 Ok(Some(dst))
168 }
169 StorageBackend::Postgres => {
170 if let Some(backend) = source_backend {
171 let export = backend.export_all().await?;
172 let backup_path = self
173 .data_dir
174 .join(format!("postgres_backup_{}.json", timestamp));
175
176 let json = serde_json::to_string_pretty(&export).map_err(|e| {
177 DomainError::DatabaseError(format!(
178 "Failed to serialize PostgreSQL backup: {}",
179 e
180 ))
181 })?;
182
183 tokio::fs::write(&backup_path, json).await.map_err(|e| {
184 DomainError::DatabaseError(format!(
185 "Failed to write PostgreSQL backup: {}",
186 e
187 ))
188 })?;
189
190 tracing::info!("PostgreSQL database exported to: {:?}", backup_path);
191 Ok(Some(backup_path))
192 } else {
193 tracing::warn!("No source backend provided for PostgreSQL backup");
194 Ok(None)
195 }
196 }
197 }
198 }
199
200 async fn backup_config_file(&self, timestamp: &str) -> DomainResult<PathBuf> {
201 use crate::infrastructure::config::ConfigLoader;
202
203 let config_path = ConfigLoader::discover_config_path();
204 if !config_path.exists() {
205 return Err(DomainError::InvalidConfig(format!(
206 "Config file not found: {:?}",
207 config_path
208 )));
209 }
210
211 let backup_path = config_path.with_file_name(format!("config.toml.backup-{}", timestamp));
212 tokio::fs::copy(&config_path, &backup_path)
213 .await
214 .map_err(|e| {
215 DomainError::DatabaseError(format!("Failed to backup config file: {}", e))
216 })?;
217
218 tracing::info!("Config file backed up to: {:?}", backup_path);
219 Ok(backup_path)
220 }
221
222 async fn backup_target_database(
223 &self, target_config: &PipedashConfig, timestamp: &str,
224 ) -> DomainResult<Option<PathBuf>> {
225 match target_config.storage.backend {
226 StorageBackend::Sqlite => {
227 let target_path = target_config.db_path();
228 if !target_path.exists() {
229 return Ok(None);
230 }
231
232 let metadata = tokio::fs::metadata(&target_path).await.ok();
233 if let Some(meta) = metadata {
234 if meta.len() > 0 {
235 let backup_path = target_path
236 .with_file_name(format!("pipedash_target.db.backup-{}", timestamp));
237 Self::backup_sqlite_database(&target_path, &backup_path).await?;
238
239 tracing::info!("Target database backed up to: {:?}", backup_path);
240 return Ok(Some(backup_path));
241 }
242 }
243 Ok(None)
244 }
245 StorageBackend::Postgres => Ok(None),
246 }
247 }
248
249 pub async fn restore_backups(&self, backups: &BackupPaths) -> DomainResult<()> {
250 tracing::warn!("Migration failed - restoring from backups");
251
252 use crate::infrastructure::config::ConfigLoader;
253 let config_path = ConfigLoader::discover_config_path();
254 if backups.source_config.exists() {
255 tokio::fs::copy(&backups.source_config, &config_path)
256 .await
257 .map_err(|e| {
258 DomainError::DatabaseError(format!("Failed to restore config file: {}", e))
259 })?;
260 tracing::info!("Config file restored from: {:?}", backups.source_config);
261 }
262
263 if let Some(source_db) = &backups.source_db {
264 if source_db.exists() {
265 let original_path = if source_db.extension().is_some_and(|ext| ext == "json") {
266 tracing::warn!(
267 "PostgreSQL source backup exists but cannot be automatically restored"
268 );
269 return Ok(());
270 } else {
271 source_db.parent().unwrap().join("pipedash.db")
272 };
273
274 Self::restore_sqlite_database(source_db, &original_path).await?;
275 tracing::info!("Source database restored from: {:?}", source_db);
276 }
277 }
278
279 tracing::info!("Backup restoration complete");
280 Ok(())
281 }
282
283 pub async fn cleanup_backups(&self, backups: &BackupPaths, keep: bool) -> DomainResult<()> {
284 if keep {
285 tracing::info!("Keeping backups as requested");
286 return Ok(());
287 }
288
289 tracing::info!("Cleaning up migration backups");
290
291 if let Some(source_db) = &backups.source_db {
292 if source_db.exists() {
293 tokio::fs::remove_file(source_db).await.ok();
294 tracing::debug!("Deleted source database backup: {:?}", source_db);
295 }
296 }
297
298 if backups.source_config.exists() {
299 tokio::fs::remove_file(&backups.source_config).await.ok();
300 tracing::debug!("Deleted config backup: {:?}", backups.source_config);
301 }
302
303 if let Some(target_db) = &backups.target_db {
304 if target_db.exists() {
305 tokio::fs::remove_file(target_db).await.ok();
306 tracing::debug!("Deleted target database backup: {:?}", target_db);
307 }
308 }
309
310 self.cleanup_old_backups().await?;
311
312 Ok(())
313 }
314
315 async fn cleanup_old_backups(&self) -> DomainResult<()> {
316 use std::time::{
317 Duration,
318 SystemTime,
319 };
320
321 let retention_days = 7;
322 let cutoff = SystemTime::now() - Duration::from_secs(retention_days * 24 * 3600);
323
324 let patterns = vec!["*.backup-*", "*_backup_*.json"];
325
326 for pattern in patterns {
327 let glob_pattern = self.data_dir.join(pattern);
328 if let Ok(entries) = glob::glob(glob_pattern.to_str().unwrap()) {
329 for entry in entries.flatten() {
330 if let Ok(metadata) = tokio::fs::metadata(&entry).await {
331 if let Ok(modified) = metadata.modified() {
332 if modified < cutoff {
333 tokio::fs::remove_file(&entry).await.ok();
334 tracing::debug!("Deleted old backup: {:?}", entry);
335 }
336 }
337 }
338 }
339 }
340 }
341
342 Ok(())
343 }
344
345 pub async fn estimate_backup_size(&self, config: &PipedashConfig) -> u64 {
346 let mut total_size = 0u64;
347
348 let db_path = config.db_path();
349 if let Ok(metadata) = tokio::fs::metadata(&db_path).await {
350 total_size += metadata.len();
351 }
352
353 use crate::infrastructure::config::ConfigLoader;
354 let config_path = ConfigLoader::discover_config_path();
355 if let Ok(metadata) = tokio::fs::metadata(&config_path).await {
356 total_size += metadata.len();
357 }
358
359 total_size
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 async fn check_wal_backup(target: bool) {
368 let dir = tempfile::tempdir().unwrap();
369 let mut config = PipedashConfig::default();
370 config.storage.data_dir = dir.path().to_str().unwrap().to_owned();
371 let pool = crate::infrastructure::database::init_database(config.db_path())
372 .await
373 .unwrap();
374 let mut writer = pool.acquire().await.unwrap();
375 sqlx::query("PRAGMA wal_autocheckpoint = 0")
376 .execute(&mut *writer)
377 .await
378 .unwrap();
379 sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
380 .execute(&mut *writer)
381 .await
382 .unwrap();
383 sqlx::query(
384 "INSERT INTO providers (id, name, provider_type, token_encrypted) VALUES (42, 'committed', 'jenkins', '')",
385 )
386 .execute(&mut *writer)
387 .await
388 .unwrap();
389 let mut transaction = writer.begin().await.unwrap();
390 sqlx::query(
391 "INSERT INTO providers (name, provider_type, token_encrypted) VALUES ('uncommitted', 'jenkins', '')",
392 )
393 .execute(&mut *transaction)
394 .await
395 .unwrap();
396
397 let manager = BackupManager::new(dir.path().to_path_buf());
398 let backup = if target {
399 manager.backup_target_database(&config, "wal").await
400 } else {
401 manager.backup_source_database(&config, None, "wal").await
402 }
403 .unwrap()
404 .unwrap();
405 let standalone = tempfile::tempdir().unwrap();
406 let snapshot_path = standalone.path().join("snapshot.db");
407 tokio::fs::copy(backup, &snapshot_path).await.unwrap();
408 let mut snapshot = SqliteConnection::connect_with(
409 &SqliteConnectOptions::new()
410 .filename(snapshot_path)
411 .read_only(true),
412 )
413 .await
414 .unwrap();
415 let providers: Vec<(i64, String)> =
416 sqlx::query_as("SELECT id, name FROM providers ORDER BY id")
417 .fetch_all(&mut snapshot)
418 .await
419 .unwrap();
420 assert_eq!(providers, vec![(42, "committed".to_owned())]);
421 let integrity: String = sqlx::query_scalar("PRAGMA integrity_check")
422 .fetch_one(&mut snapshot)
423 .await
424 .unwrap();
425 assert_eq!(integrity, "ok");
426 snapshot.close().await.unwrap();
427 transaction.rollback().await.unwrap();
428 drop(writer);
429 pool.close().await;
430 }
431
432 #[tokio::test]
433 async fn source_backup_includes_committed_wal_providers() {
434 check_wal_backup(false).await;
435 }
436
437 #[tokio::test]
438 async fn target_backup_includes_committed_wal_providers() {
439 check_wal_backup(true).await;
440 }
441
442 #[tokio::test]
443 async fn restore_keeps_open_wal_source_consistent() {
444 let dir = tempfile::tempdir().unwrap();
445 let mut config = PipedashConfig::default();
446 config.storage.data_dir = dir.path().to_str().unwrap().to_owned();
447 let pool = crate::infrastructure::database::init_database(config.db_path())
448 .await
449 .unwrap();
450 let mut writer = pool.acquire().await.unwrap();
451 for statement in [
452 "PRAGMA wal_autocheckpoint = 0",
453 "WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 200) \
454 INSERT INTO providers (name, provider_type, token_encrypted) \
455 SELECT 'bulk' || i, 'jenkins', hex(randomblob(1000)) FROM n",
456 "PRAGMA wal_checkpoint(TRUNCATE)",
457 "DELETE FROM providers",
458 "INSERT INTO providers (id, name, provider_type, token_encrypted) VALUES (42, 'kept', 'jenkins', '')",
459 ] {
460 sqlx::query(statement).execute(&mut *writer).await.unwrap();
461 }
462
463 let manager = BackupManager::new(dir.path().to_path_buf());
464 let source_db = manager
465 .backup_source_database(&config, None, "restore")
466 .await
467 .unwrap();
468 sqlx::query("INSERT INTO providers (name, provider_type, token_encrypted) VALUES ('failed', 'jenkins', '')")
469 .execute(&mut *writer)
470 .await
471 .unwrap();
472 let backups = BackupPaths {
473 source_db,
474 source_config: dir.path().join("missing-config.toml.backup"),
475 target_db: None,
476 timestamp: "restore".into(),
477 };
478 manager.restore_backups(&backups).await.unwrap();
479
480 let integrity: String = sqlx::query_scalar("PRAGMA integrity_check")
481 .fetch_one(&mut *writer)
482 .await
483 .unwrap();
484 assert_eq!(integrity, "ok");
485 let providers: Vec<(i64, String)> =
486 sqlx::query_as("SELECT id, name FROM providers ORDER BY id")
487 .fetch_all(&mut *writer)
488 .await
489 .unwrap();
490 assert_eq!(providers, vec![(42, "kept".to_owned())]);
491 drop(writer);
492 pool.close().await;
493 }
494
495 #[tokio::test]
496 async fn restore_keeps_sqlite_file_after_postgres_backup() {
497 let dir = tempfile::tempdir().unwrap();
498 let config_path = dir.path().join("config.toml");
499 std::env::set_var("PIPEDASH_CONFIG_PATH", &config_path);
500 tokio::fs::write(&config_path, "[storage]\nbackend = \"postgres\"\n")
501 .await
502 .unwrap();
503 let config_backup = dir.path().join("config.toml.backup-x");
504 tokio::fs::copy(&config_path, &config_backup).await.unwrap();
505 let sqlite_path = dir.path().join("pipedash.db");
506 tokio::fs::write(&sqlite_path, b"SQLite format 3\0")
507 .await
508 .unwrap();
509 let json_backup = dir.path().join("postgres_backup_x.json");
510 tokio::fs::write(&json_backup, "{\"providers\": []}")
511 .await
512 .unwrap();
513 let backups = BackupPaths {
514 source_db: Some(json_backup),
515 source_config: config_backup,
516 target_db: None,
517 timestamp: "x".into(),
518 };
519
520 BackupManager::new(dir.path().to_path_buf())
521 .restore_backups(&backups)
522 .await
523 .unwrap();
524
525 let after = tokio::fs::read(&sqlite_path).await.unwrap();
526 assert_eq!(
527 after,
528 b"SQLite format 3\0",
529 "{}",
530 String::from_utf8_lossy(&after)
531 );
532 }
533}