pipedash_core/infrastructure/migration/
backup.rs1use 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, ×tamp)
46 .await?;
47
48 let config_backup = self.backup_config_file(×tamp).await?;
49
50 let target_db_backup = self
51 .backup_target_database(target_config, ×tamp)
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 async fn test_backup_manager_creation() {
300 let temp_dir = std::env::temp_dir().join("pipedash_backup_test");
301 let manager = BackupManager::new(temp_dir);
302 assert!(manager.data_dir.ends_with("pipedash_backup_test"));
303 }
304}