1use 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 SQLite 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 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 } else {
270 let original_path = source_db.parent().unwrap().join("pipedash.db");
271 Self::restore_sqlite_database(source_db, &original_path).await?;
272 tracing::info!("Source database restored from: {:?}", source_db);
273 }
274 }
275 }
276
277 if let Some(target_db) = &backups.target_db {
278 if target_db.exists() {
279 let original_path = target_db.parent().unwrap().join("pipedash.db");
280 Self::restore_sqlite_database(target_db, &original_path).await?;
281 tracing::info!("Target database restored from: {:?}", target_db);
282 }
283 }
284
285 tracing::info!("Backup restoration complete");
286 Ok(())
287 }
288
289 pub async fn cleanup_backups(&self, backups: &BackupPaths, keep: bool) -> DomainResult<()> {
290 if keep {
291 tracing::info!("Keeping backups as requested");
292 return Ok(());
293 }
294
295 tracing::info!("Cleaning up migration backups");
296
297 if let Some(source_db) = &backups.source_db {
298 if source_db.exists() {
299 tokio::fs::remove_file(source_db).await.ok();
300 tracing::debug!("Deleted source database backup: {:?}", source_db);
301 }
302 }
303
304 if backups.source_config.exists() {
305 tokio::fs::remove_file(&backups.source_config).await.ok();
306 tracing::debug!("Deleted config backup: {:?}", backups.source_config);
307 }
308
309 if let Some(target_db) = &backups.target_db {
310 if target_db.exists() {
311 tokio::fs::remove_file(target_db).await.ok();
312 tracing::debug!("Deleted target database backup: {:?}", target_db);
313 }
314 }
315
316 self.cleanup_old_backups().await?;
317
318 Ok(())
319 }
320
321 async fn cleanup_old_backups(&self) -> DomainResult<()> {
322 use std::time::{
323 Duration,
324 SystemTime,
325 };
326
327 let retention_days = 7;
328 let cutoff = SystemTime::now() - Duration::from_secs(retention_days * 24 * 3600);
329
330 let patterns = vec!["*.backup-*", "*_backup_*.json"];
331
332 for pattern in patterns {
333 let glob_pattern = self.data_dir.join(pattern);
334 if let Ok(entries) = glob::glob(glob_pattern.to_str().unwrap()) {
335 for entry in entries.flatten() {
336 if let Ok(metadata) = tokio::fs::metadata(&entry).await {
337 if let Ok(modified) = metadata.modified() {
338 if modified < cutoff {
339 tokio::fs::remove_file(&entry).await.ok();
340 tracing::debug!("Deleted old backup: {:?}", entry);
341 }
342 }
343 }
344 }
345 }
346 }
347
348 Ok(())
349 }
350
351 pub async fn estimate_backup_size(&self, config: &PipedashConfig) -> u64 {
352 let mut total_size = 0u64;
353
354 let db_path = config.db_path();
355 if let Ok(metadata) = tokio::fs::metadata(&db_path).await {
356 total_size += metadata.len();
357 }
358
359 use crate::infrastructure::config::ConfigLoader;
360 let config_path = ConfigLoader::discover_config_path();
361 if let Ok(metadata) = tokio::fs::metadata(&config_path).await {
362 total_size += metadata.len();
363 }
364
365 total_size
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 async fn check_wal_backup(target: bool) {
374 let dir = tempfile::tempdir().unwrap();
375 let mut config = PipedashConfig::default();
376 config.storage.data_dir = dir.path().to_str().unwrap().to_owned();
377 let pool = crate::infrastructure::database::init_database(config.db_path())
378 .await
379 .unwrap();
380 let mut writer = pool.acquire().await.unwrap();
381 sqlx::query("PRAGMA wal_autocheckpoint = 0")
382 .execute(&mut *writer)
383 .await
384 .unwrap();
385 sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
386 .execute(&mut *writer)
387 .await
388 .unwrap();
389 sqlx::query(
390 "INSERT INTO providers (id, name, provider_type, token_encrypted) VALUES (42, 'committed', 'jenkins', '')",
391 )
392 .execute(&mut *writer)
393 .await
394 .unwrap();
395 let mut transaction = writer.begin().await.unwrap();
396 sqlx::query(
397 "INSERT INTO providers (name, provider_type, token_encrypted) VALUES ('uncommitted', 'jenkins', '')",
398 )
399 .execute(&mut *transaction)
400 .await
401 .unwrap();
402
403 let manager = BackupManager::new(dir.path().to_path_buf());
404 let backup = if target {
405 manager.backup_target_database(&config, "wal").await
406 } else {
407 manager.backup_source_database(&config, None, "wal").await
408 }
409 .unwrap()
410 .unwrap();
411 let standalone = tempfile::tempdir().unwrap();
412 let snapshot_path = standalone.path().join("snapshot.db");
413 tokio::fs::copy(backup, &snapshot_path).await.unwrap();
414 let mut snapshot = SqliteConnection::connect_with(
415 &SqliteConnectOptions::new()
416 .filename(snapshot_path)
417 .read_only(true),
418 )
419 .await
420 .unwrap();
421 let providers: Vec<(i64, String)> =
422 sqlx::query_as("SELECT id, name FROM providers ORDER BY id")
423 .fetch_all(&mut snapshot)
424 .await
425 .unwrap();
426 assert_eq!(providers, vec![(42, "committed".to_owned())]);
427 let integrity: String = sqlx::query_scalar("PRAGMA integrity_check")
428 .fetch_one(&mut snapshot)
429 .await
430 .unwrap();
431 assert_eq!(integrity, "ok");
432 snapshot.close().await.unwrap();
433 transaction.rollback().await.unwrap();
434 drop(writer);
435 pool.close().await;
436 }
437
438 #[tokio::test]
439 async fn source_backup_includes_committed_wal_providers() {
440 check_wal_backup(false).await;
441 }
442
443 #[tokio::test]
444 async fn target_backup_includes_committed_wal_providers() {
445 check_wal_backup(true).await;
446 }
447
448 #[tokio::test]
449 async fn restore_keeps_open_wal_source_consistent() {
450 let dir = tempfile::tempdir().unwrap();
451 let mut config = PipedashConfig::default();
452 config.storage.data_dir = dir.path().to_str().unwrap().to_owned();
453 let pool = crate::infrastructure::database::init_database(config.db_path())
454 .await
455 .unwrap();
456 let mut writer = pool.acquire().await.unwrap();
457 for statement in [
458 "PRAGMA wal_autocheckpoint = 0",
459 "WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 200) \
460 INSERT INTO providers (name, provider_type, token_encrypted) \
461 SELECT 'bulk' || i, 'jenkins', hex(randomblob(1000)) FROM n",
462 "PRAGMA wal_checkpoint(TRUNCATE)",
463 "DELETE FROM providers",
464 "INSERT INTO providers (id, name, provider_type, token_encrypted) VALUES (42, 'kept', 'jenkins', '')",
465 ] {
466 sqlx::query(statement).execute(&mut *writer).await.unwrap();
467 }
468
469 let manager = BackupManager::new(dir.path().to_path_buf());
470 let source_db = manager
471 .backup_source_database(&config, None, "restore")
472 .await
473 .unwrap();
474 sqlx::query("INSERT INTO providers (name, provider_type, token_encrypted) VALUES ('failed', 'jenkins', '')")
475 .execute(&mut *writer)
476 .await
477 .unwrap();
478 let backups = BackupPaths {
479 source_db,
480 source_config: dir.path().join("missing-config.toml.backup"),
481 target_db: None,
482 timestamp: "restore".into(),
483 };
484 manager.restore_backups(&backups).await.unwrap();
485
486 let integrity: String = sqlx::query_scalar("PRAGMA integrity_check")
487 .fetch_one(&mut *writer)
488 .await
489 .unwrap();
490 assert_eq!(integrity, "ok");
491 let providers: Vec<(i64, String)> =
492 sqlx::query_as("SELECT id, name FROM providers ORDER BY id")
493 .fetch_all(&mut *writer)
494 .await
495 .unwrap();
496 assert_eq!(providers, vec![(42, "kept".to_owned())]);
497 drop(writer);
498 pool.close().await;
499 }
500
501 async fn check_target_restore(postgres_source: bool) {
502 let source_dir = tempfile::tempdir().unwrap();
503 let target_dir = tempfile::tempdir().unwrap();
504 let mut target_config = PipedashConfig::default();
505 target_config.storage.data_dir = target_dir.path().to_str().unwrap().to_owned();
506 let pool = crate::infrastructure::database::init_database(target_config.db_path())
507 .await
508 .unwrap();
509 sqlx::query("INSERT INTO providers (id, name, provider_type, token_encrypted) VALUES (7, 'original', 'jenkins', '')")
510 .execute(&pool)
511 .await
512 .unwrap();
513
514 let manager = BackupManager::new(source_dir.path().to_path_buf());
515 let target_db = manager
516 .backup_target_database(&target_config, "target")
517 .await
518 .unwrap();
519 sqlx::query("INSERT INTO providers (name, provider_type, token_encrypted) VALUES ('first', 'jenkins', '')")
520 .execute(&pool)
521 .await
522 .unwrap();
523 let source_db = if postgres_source {
524 let json_backup = source_dir.path().join("postgres_backup_target.json");
525 tokio::fs::write(&json_backup, "{\"providers\": []}")
526 .await
527 .unwrap();
528 Some(json_backup)
529 } else {
530 None
531 };
532 let backups = BackupPaths {
533 source_db,
534 source_config: source_dir.path().join("missing-config.toml.backup"),
535 target_db,
536 timestamp: "target".into(),
537 };
538 manager.restore_backups(&backups).await.unwrap();
539
540 let providers: Vec<(i64, String)> =
541 sqlx::query_as("SELECT id, name FROM providers ORDER BY id")
542 .fetch_all(&pool)
543 .await
544 .unwrap();
545 assert_eq!(providers, vec![(7, "original".to_owned())]);
546 pool.close().await;
547 }
548
549 #[tokio::test]
550 async fn restore_drops_committed_target_writes() {
551 check_target_restore(false).await;
552 }
553
554 #[tokio::test]
555 async fn restore_drops_committed_target_writes_after_postgres_source() {
556 check_target_restore(true).await;
557 }
558
559 #[tokio::test]
560 async fn restore_keeps_sqlite_file_after_postgres_backup() {
561 let dir = tempfile::tempdir().unwrap();
562 let config_path = dir.path().join("config.toml");
563 std::env::set_var("PIPEDASH_CONFIG_PATH", &config_path);
564 tokio::fs::write(&config_path, "[storage]\nbackend = \"postgres\"\n")
565 .await
566 .unwrap();
567 let config_backup = dir.path().join("config.toml.backup-x");
568 tokio::fs::copy(&config_path, &config_backup).await.unwrap();
569 let sqlite_path = dir.path().join("pipedash.db");
570 tokio::fs::write(&sqlite_path, b"SQLite format 3\0")
571 .await
572 .unwrap();
573 let json_backup = dir.path().join("postgres_backup_x.json");
574 tokio::fs::write(&json_backup, "{\"providers\": []}")
575 .await
576 .unwrap();
577 let backups = BackupPaths {
578 source_db: Some(json_backup),
579 source_config: config_backup,
580 target_db: None,
581 timestamp: "x".into(),
582 };
583
584 BackupManager::new(dir.path().to_path_buf())
585 .restore_backups(&backups)
586 .await
587 .unwrap();
588
589 let after = tokio::fs::read(&sqlite_path).await.unwrap();
590 assert_eq!(
591 after,
592 b"SQLite format 3\0",
593 "{}",
594 String::from_utf8_lossy(&after)
595 );
596 }
597}