Skip to main content

pipedash_core/infrastructure/migration/
backup.rs

1use std::path::PathBuf;
2
3use chrono::Utc;
4use serde::{
5    Deserialize,
6    Serialize,
7};
8
9use crate::domain::{
10    DomainError,
11    DomainResult,
12};
13use crate::infrastructure::config::{
14    PipedashConfig,
15    StorageBackend,
16};
17use crate::infrastructure::ConfigBackend;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct BackupPaths {
21    pub source_db: Option<PathBuf>,
22    pub source_config: PathBuf,
23    pub target_db: Option<PathBuf>,
24    pub timestamp: String,
25}
26
27pub struct BackupManager {
28    data_dir: PathBuf,
29}
30
31impl BackupManager {
32    pub fn new(data_dir: PathBuf) -> Self {
33        Self { data_dir }
34    }
35
36    pub async fn create_backups(
37        &self, source_config: &PipedashConfig, target_config: &PipedashConfig,
38        source_backend: Option<&dyn ConfigBackend>,
39    ) -> DomainResult<BackupPaths> {
40        let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
41
42        tracing::info!("Creating migration backups with timestamp: {}", timestamp);
43
44        let source_db_backup = self
45            .backup_source_database(source_config, source_backend, &timestamp)
46            .await?;
47
48        let config_backup = self.backup_config_file(&timestamp).await?;
49
50        let target_db_backup = self
51            .backup_target_database(target_config, &timestamp)
52            .await?;
53
54        let backups = BackupPaths {
55            source_db: source_db_backup,
56            source_config: config_backup,
57            target_db: target_db_backup,
58            timestamp,
59        };
60
61        tracing::info!("Backups created successfully: {:?}", backups);
62
63        Ok(backups)
64    }
65
66    async fn backup_source_database(
67        &self, source_config: &PipedashConfig, source_backend: Option<&dyn ConfigBackend>,
68        timestamp: &str,
69    ) -> DomainResult<Option<PathBuf>> {
70        match source_config.storage.backend {
71            StorageBackend::Sqlite => {
72                let src = source_config.db_path();
73                if !src.exists() {
74                    tracing::warn!("Source database does not exist: {:?}", src);
75                    return Ok(None);
76                }
77
78                let dst = src.with_file_name(format!("pipedash.db.backup-{}", timestamp));
79                tokio::fs::copy(&src, &dst).await.map_err(|e| {
80                    DomainError::DatabaseError(format!("Failed to backup SQLite database: {}", e))
81                })?;
82
83                tracing::info!("SQLite database backed up to: {:?}", dst);
84                Ok(Some(dst))
85            }
86            StorageBackend::Postgres => {
87                if let Some(backend) = source_backend {
88                    let export = backend.export_all().await?;
89                    let backup_path = self
90                        .data_dir
91                        .join(format!("postgres_backup_{}.json", timestamp));
92
93                    let json = serde_json::to_string_pretty(&export).map_err(|e| {
94                        DomainError::DatabaseError(format!(
95                            "Failed to serialize PostgreSQL backup: {}",
96                            e
97                        ))
98                    })?;
99
100                    tokio::fs::write(&backup_path, json).await.map_err(|e| {
101                        DomainError::DatabaseError(format!(
102                            "Failed to write PostgreSQL backup: {}",
103                            e
104                        ))
105                    })?;
106
107                    tracing::info!("PostgreSQL database exported to: {:?}", backup_path);
108                    Ok(Some(backup_path))
109                } else {
110                    tracing::warn!("No source backend provided for PostgreSQL backup");
111                    Ok(None)
112                }
113            }
114        }
115    }
116
117    async fn backup_config_file(&self, timestamp: &str) -> DomainResult<PathBuf> {
118        use crate::infrastructure::config::ConfigLoader;
119
120        let config_path = ConfigLoader::discover_config_path();
121        if !config_path.exists() {
122            return Err(DomainError::InvalidConfig(format!(
123                "Config file not found: {:?}",
124                config_path
125            )));
126        }
127
128        let backup_path = config_path.with_file_name(format!("config.toml.backup-{}", timestamp));
129        tokio::fs::copy(&config_path, &backup_path)
130            .await
131            .map_err(|e| {
132                DomainError::DatabaseError(format!("Failed to backup config file: {}", e))
133            })?;
134
135        tracing::info!("Config file backed up to: {:?}", backup_path);
136        Ok(backup_path)
137    }
138
139    async fn backup_target_database(
140        &self, target_config: &PipedashConfig, timestamp: &str,
141    ) -> DomainResult<Option<PathBuf>> {
142        match target_config.storage.backend {
143            StorageBackend::Sqlite => {
144                let target_path = target_config.db_path();
145                if !target_path.exists() {
146                    return Ok(None);
147                }
148
149                let metadata = tokio::fs::metadata(&target_path).await.ok();
150                if let Some(meta) = metadata {
151                    if meta.len() > 0 {
152                        let backup_path = target_path
153                            .with_file_name(format!("pipedash_target.db.backup-{}", timestamp));
154                        tokio::fs::copy(&target_path, &backup_path)
155                            .await
156                            .map_err(|e| {
157                                DomainError::DatabaseError(format!(
158                                    "Failed to backup target database: {}",
159                                    e
160                                ))
161                            })?;
162
163                        tracing::info!("Target database backed up to: {:?}", backup_path);
164                        return Ok(Some(backup_path));
165                    }
166                }
167                Ok(None)
168            }
169            StorageBackend::Postgres => Ok(None),
170        }
171    }
172
173    pub async fn restore_backups(&self, backups: &BackupPaths) -> DomainResult<()> {
174        tracing::warn!("Migration failed - restoring from backups");
175
176        use crate::infrastructure::config::ConfigLoader;
177        let config_path = ConfigLoader::discover_config_path();
178        if backups.source_config.exists() {
179            tokio::fs::copy(&backups.source_config, &config_path)
180                .await
181                .map_err(|e| {
182                    DomainError::DatabaseError(format!("Failed to restore config file: {}", e))
183                })?;
184            tracing::info!("Config file restored from: {:?}", backups.source_config);
185        }
186
187        if let Some(source_db) = &backups.source_db {
188            if source_db.exists() {
189                let original_path = if source_db.ends_with(".json") {
190                    tracing::warn!(
191                        "PostgreSQL source backup exists but cannot be automatically restored"
192                    );
193                    return Ok(());
194                } else {
195                    source_db.parent().unwrap().join("pipedash.db")
196                };
197
198                tokio::fs::copy(source_db, &original_path)
199                    .await
200                    .map_err(|e| {
201                        DomainError::DatabaseError(format!(
202                            "Failed to restore source database: {}",
203                            e
204                        ))
205                    })?;
206                tracing::info!("Source database restored from: {:?}", source_db);
207            }
208        }
209
210        tracing::info!("Backup restoration complete");
211        Ok(())
212    }
213
214    pub async fn cleanup_backups(&self, backups: &BackupPaths, keep: bool) -> DomainResult<()> {
215        if keep {
216            tracing::info!("Keeping backups as requested");
217            return Ok(());
218        }
219
220        tracing::info!("Cleaning up migration backups");
221
222        if let Some(source_db) = &backups.source_db {
223            if source_db.exists() {
224                tokio::fs::remove_file(source_db).await.ok();
225                tracing::debug!("Deleted source database backup: {:?}", source_db);
226            }
227        }
228
229        if backups.source_config.exists() {
230            tokio::fs::remove_file(&backups.source_config).await.ok();
231            tracing::debug!("Deleted config backup: {:?}", backups.source_config);
232        }
233
234        if let Some(target_db) = &backups.target_db {
235            if target_db.exists() {
236                tokio::fs::remove_file(target_db).await.ok();
237                tracing::debug!("Deleted target database backup: {:?}", target_db);
238            }
239        }
240
241        self.cleanup_old_backups().await?;
242
243        Ok(())
244    }
245
246    async fn cleanup_old_backups(&self) -> DomainResult<()> {
247        use std::time::{
248            Duration,
249            SystemTime,
250        };
251
252        let retention_days = 7;
253        let cutoff = SystemTime::now() - Duration::from_secs(retention_days * 24 * 3600);
254
255        let patterns = vec!["*.backup-*", "*_backup_*.json"];
256
257        for pattern in patterns {
258            let glob_pattern = self.data_dir.join(pattern);
259            if let Ok(entries) = glob::glob(glob_pattern.to_str().unwrap()) {
260                for entry in entries.flatten() {
261                    if let Ok(metadata) = tokio::fs::metadata(&entry).await {
262                        if let Ok(modified) = metadata.modified() {
263                            if modified < cutoff {
264                                tokio::fs::remove_file(&entry).await.ok();
265                                tracing::debug!("Deleted old backup: {:?}", entry);
266                            }
267                        }
268                    }
269                }
270            }
271        }
272
273        Ok(())
274    }
275
276    pub async fn estimate_backup_size(&self, config: &PipedashConfig) -> u64 {
277        let mut total_size = 0u64;
278
279        let db_path = config.db_path();
280        if let Ok(metadata) = tokio::fs::metadata(&db_path).await {
281            total_size += metadata.len();
282        }
283
284        use crate::infrastructure::config::ConfigLoader;
285        let config_path = ConfigLoader::discover_config_path();
286        if let Ok(metadata) = tokio::fs::metadata(&config_path).await {
287            total_size += metadata.len();
288        }
289
290        total_size
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[tokio::test]
299    #[ignore] // Requires filesystem access
300    async fn test_backup_manager_creation() {
301        let temp_dir = std::env::temp_dir().join("pipedash_backup_test");
302        let manager = BackupManager::new(temp_dir);
303        assert!(manager.data_dir.ends_with("pipedash_backup_test"));
304    }
305}