Skip to main content

client_core/
backup.rs

1use crate::{
2    container::DockerManager,
3    database::{BackupRecord, BackupStatus, BackupType, Database},
4    error::DuckError,
5};
6use anyhow::Result;
7use chrono::Utc;
8use flate2::Compression;
9use flate2::read::GzDecoder;
10use flate2::write::GzEncoder;
11use std::path::{Path, PathBuf};
12use std::{fs::File, sync::Arc};
13use tar::Archive;
14use tar::Builder;
15use tracing::{debug, error, info, warn};
16use walkdir::WalkDir;
17
18/// 备份管理器
19#[derive(Debug, Clone)]
20pub struct BackupManager {
21    storage_dir: PathBuf,
22    database: Arc<Database>,
23    docker_manager: Arc<DockerManager>,
24}
25
26/// 备份选项
27#[derive(Debug, Clone)]
28pub struct BackupOptions {
29    /// 备份类型
30    pub backup_type: BackupType,
31    /// 服务版本
32    pub service_version: String,
33    /// 工作目录
34    pub work_dir: PathBuf,
35    /// 要备份的文件或目录列表
36    pub source_paths: Vec<PathBuf>,
37    /// 压缩级别 (0-9)
38    pub compression_level: u32,
39}
40
41/// 恢复选项
42#[derive(Debug, Clone)]
43pub struct RestoreOptions {
44    /// 目标目录
45    pub target_dir: PathBuf,
46    /// 是否强制覆盖
47    pub force_overwrite: bool,
48}
49
50impl BackupManager {
51    /// 创建新的备份管理器
52    pub fn new(
53        storage_dir: PathBuf,
54        database: Arc<Database>,
55        docker_manager: Arc<DockerManager>,
56    ) -> Result<Self> {
57        if !storage_dir.exists() {
58            std::fs::create_dir_all(&storage_dir)?;
59        }
60
61        Ok(Self {
62            storage_dir,
63            database,
64            docker_manager,
65        })
66    }
67
68    /// 创建备份
69    pub async fn create_backup(&self, options: BackupOptions) -> Result<BackupRecord> {
70        // 检查所有源路径是否存在
71        let need_backup_paths = options.source_paths;
72
73        // 生成备份文件名(人类易读格式)
74        let timestamp = Utc::now().format("%Y-%m-%d_%H-%M-%S");
75        let backup_type_str = match options.backup_type {
76            BackupType::Manual => "manual",
77            BackupType::PreUpgrade => "pre-upgrade",
78        };
79
80        let backup_filename = format!(
81            "backup_{}_v{}_{}.tar.gz",
82            backup_type_str, options.service_version, timestamp
83        );
84
85        let backup_path = self.storage_dir.join(&backup_filename);
86
87        info!("Starting to create backup: {}", backup_path.display());
88
89        // 执行备份
90        match self
91            .perform_backup(&need_backup_paths, &backup_path, options.compression_level)
92            .await
93        {
94            Ok(_) => {
95                info!("Backup created successfully: {}", backup_path.display());
96
97                // 记录到数据库
98                let record_id = self
99                    .database
100                    .create_backup_record(
101                        backup_path.to_string_lossy().to_string(),
102                        options.service_version,
103                        options.backup_type,
104                        BackupStatus::Completed,
105                    )
106                    .await?;
107
108                // 获取创建的记录
109                self.database
110                    .get_backup_by_id(record_id)
111                    .await?
112                    .ok_or_else(|| anyhow::anyhow!("Cannot get the backup record just created"))
113            }
114            Err(e) => {
115                error!("Backup creation failed: {}", e);
116
117                // 记录失败到数据库
118                self.database
119                    .create_backup_record(
120                        backup_path.to_string_lossy().to_string(),
121                        options.service_version,
122                        options.backup_type,
123                        BackupStatus::Failed,
124                    )
125                    .await?;
126
127                Err(e)
128            }
129        }
130    }
131
132    /// 执行实际的备份操作
133    ///
134    /// 支持备份目录和单个文件:
135    /// - 当传入目录路径时,将递归备份该目录下的所有文件
136    /// - 当传入文件路径时,将直接备份该文件
137    async fn perform_backup(
138        &self,
139        source_paths: &[PathBuf],
140        backup_path: &Path,
141        compression_level: u32,
142    ) -> Result<()> {
143        // 确保备份目录存在
144        if let Some(parent) = backup_path.parent() {
145            tokio::fs::create_dir_all(parent).await?;
146        }
147
148        // 在后台线程中执行压缩操作,避免阻塞异步运行时
149        let source_paths = source_paths.to_vec();
150        let backup_path = backup_path.to_path_buf();
151
152        tokio::task::spawn_blocking(move || {
153            let file = File::create(&backup_path)?;
154            let compression = Compression::new(compression_level);
155            let encoder = GzEncoder::new(file, compression);
156            let mut archive = Builder::new(encoder);
157
158            // 遍历所有源路径并添加到归档中
159            for source_path in &source_paths {
160                if source_path.is_file() {
161                    // 直接处理单个文件
162                    add_file_to_archive(&mut archive, source_path, None)?;
163                } else if source_path.is_dir() {
164                    let dir_name = source_path
165                        .file_name()
166                        .ok_or_else(|| anyhow::anyhow!("Cannot get directory name"))?
167                        .to_string_lossy()
168                        .to_string();
169
170                    // 递归处理目录
171                    for entry in WalkDir::new(source_path) {
172                        let entry = entry
173                            .map_err(|e| anyhow::anyhow!("Failed to traverse directory: {e}"))?;
174                        let path = entry.path();
175
176                        if path.is_file() {
177                            add_file_to_archive(
178                                &mut archive,
179                                path,
180                                Some((source_path, &dir_name)),
181                            )?;
182                        }
183                    }
184                } else {
185                    //可能是新增的文件或者目录,这里无法备份,只打印日志
186                    info!(
187                        "File or directory does not exist, no need to backup: {}",
188                        source_path.display()
189                    );
190                }
191            }
192
193            archive
194                .finish()
195                .map_err(|e| anyhow::anyhow!("Failed to finish archive: {e}"))?;
196
197            Ok::<(), anyhow::Error>(())
198        })
199        .await??;
200
201        Ok(())
202    }
203
204    /// 只恢复数据文件,保留配置文件的智能恢复
205    pub async fn restore_data_from_backup_with_exculde(
206        &self,
207        backup_id: i64,
208        target_dir: &Path,
209        auto_start_service: bool,
210        dirs_to_exculde: &[&str],
211    ) -> Result<()> {
212        // 获取备份记录
213        let backup_record = self
214            .database
215            .get_backup_by_id(backup_id)
216            .await?
217            .ok_or_else(|| anyhow::anyhow!("Backup record does not exist: {backup_id}"))?;
218
219        let backup_path = PathBuf::from(&backup_record.file_path);
220        if !backup_path.exists() {
221            return Err(anyhow::anyhow!(
222                "Backup file does not exist: {}",
223                backup_path.display()
224            ));
225        }
226
227        info!(
228            "Starting intelligent data restore: {}",
229            backup_path.display()
230        );
231        info!("Target directory: {}", target_dir.display());
232
233        // 停止服务,准备恢复
234        info!("Stopping services...");
235        self.docker_manager.stop_services().await?;
236
237        // 清理现有数据目录,但保留配置文件
238        self.clear_data_directories(target_dir, dirs_to_exculde)
239            .await?;
240
241        // 执行恢复
242        self.perform_restore(&backup_path, target_dir, dirs_to_exculde)
243            .await?;
244
245        // 根据参数决定是否启动服务
246        if auto_start_service {
247            info!("Data restore completed, starting services...");
248            self.docker_manager.start_services().await?;
249            info!(
250                "Data restored and started successfully: {}",
251                target_dir.display()
252            );
253        } else {
254            info!("Data restore completed, skipping service start (controlled by parent process)");
255            info!("Data restored successfully: {}", target_dir.display());
256        }
257
258        Ok(())
259    }
260
261    /// 只恢复 data 目录,保留 app 目录和配置文件
262    pub async fn restore_data_directory_only(
263        &self,
264        backup_id: i64,
265        target_dir: &Path,
266        auto_start_service: bool,
267        dirs_to_restore: &[&str],
268    ) -> Result<()> {
269        // 获取备份记录
270        let backup_record = self
271            .database
272            .get_backup_by_id(backup_id)
273            .await?
274            .ok_or_else(|| anyhow::anyhow!("Backup record does not exist: {backup_id}"))?;
275
276        let backup_path = PathBuf::from(&backup_record.file_path);
277        if !backup_path.exists() {
278            return Err(anyhow::anyhow!(
279                "Backup file does not exist: {}",
280                backup_path.display()
281            ));
282        }
283
284        info!("Starting data directory restore: {}", backup_path.display());
285        info!("Target directory: {}", target_dir.display());
286
287        // 停止服务,准备恢复
288        info!("Stopping services...");
289        self.docker_manager.stop_services().await?;
290
291        // 只清理 data 目录,保留 app 目录和配置文件
292        self.clear_data_directory_only(target_dir).await?;
293
294        // 执行选择性恢复:只恢复 data 目录
295        self.perform_selective_restore(&backup_path, target_dir, dirs_to_restore)
296            .await?;
297
298        // 根据参数决定是否启动服务
299        if auto_start_service {
300            info!("Data directory restore completed, starting services...");
301            self.docker_manager.start_services().await?;
302            info!(
303                "Data directory restored and started successfully: {}",
304                target_dir.display()
305            );
306        } else {
307            info!(
308                "Data directory restore completed, skipping service start (controlled by parent process)"
309            );
310            info!(
311                "Data directory restored successfully: {}",
312                target_dir.display()
313            );
314        }
315
316        Ok(())
317    }
318
319    /// 清理数据目录
320    async fn clear_data_directories(
321        &self,
322        docker_dir: &Path,
323        dirs_to_exculde: &[&str],
324    ) -> Result<()> {
325        let mut data_dirs_to_clear: Vec<String> = vec!["data".to_string(), "app".to_string()];
326        // Filter out directories that should be excluded from clearing
327        data_dirs_to_clear.retain(|dir| !dirs_to_exculde.contains(&dir.as_str()));
328
329        for dir_name in data_dirs_to_clear.iter() {
330            let dir_path = docker_dir.join(dir_name);
331            if dir_path.exists() {
332                info!("Cleaning data directory: {}", dir_path.display());
333                self.force_remove_directory(&dir_path).await?;
334            }
335        }
336
337        info!("Data directory cleanup completed, config files preserved");
338        Ok(())
339    }
340
341    /// 强制删除目录,处理悬挂符号链接和其他特殊情况
342    async fn force_remove_directory(&self, path: &Path) -> Result<()> {
343        if !path.exists() {
344            return Ok(());
345        }
346
347        info!("Force cleaning directory: {}", path.display());
348
349        // 先处理符号链接
350        if path.is_symlink() {
351            info!("Removing symbolic link: {}", path.display());
352            tokio::fs::remove_file(path).await?;
353            return Ok(());
354        }
355
356        // 递归删除目录内容
357        let mut entries = match tokio::fs::read_dir(path).await {
358            Ok(entries) => entries,
359            Err(e) => {
360                warn!("Failed to read directory: {} - {}", path.display(), e);
361                // 如果读取失败,尝试直接删除整个目录
362                return tokio::fs::remove_dir_all(path).await.map_err(|e| {
363                    anyhow::anyhow!("Failed to delete directory: {} - {}", path.display(), e)
364                });
365            }
366        };
367
368        while let Some(entry) = entries.next_entry().await? {
369            let entry_path = entry.path();
370
371            if entry_path.is_symlink() {
372                info!("Removing symbolic link: {}", entry_path.display());
373                tokio::fs::remove_file(&entry_path).await?;
374            } else if entry_path.is_dir() {
375                // 递归删除子目录
376                Box::pin(self.force_remove_directory(&entry_path)).await?;
377
378                // 尝试删除空目录(忽略"不存在"的错误)
379                if let Err(e) = tokio::fs::remove_dir(&entry_path).await
380                    && e.kind() != std::io::ErrorKind::NotFound
381                {
382                    warn!(
383                        "Failed to remove empty directory: {} - {}",
384                        entry_path.display(),
385                        e
386                    );
387                }
388            } else {
389                if let Err(e) = tokio::fs::remove_file(&entry_path).await
390                    && e.kind() != std::io::ErrorKind::NotFound
391                {
392                    warn!("Failed to remove file: {} - {}", entry_path.display(), e);
393                }
394            }
395        }
396
397        // 尝试删除根目录(忽略"不存在"的错误)
398        if let Err(e) = tokio::fs::remove_dir(path).await
399            && e.kind() != std::io::ErrorKind::NotFound
400        {
401            warn!(
402                "Failed to remove root directory: {} - {}",
403                path.display(),
404                e
405            );
406        }
407
408        Ok(())
409    }
410
411    /// 只清理 data 目录,保留 app 目录和配置文件
412    async fn clear_data_directory_only(&self, docker_dir: &Path) -> Result<()> {
413        let data_dir = docker_dir.join("data");
414        if data_dir.exists() {
415            info!("Cleaning data directory: {}", data_dir.display());
416            tokio::fs::remove_dir_all(&data_dir).await?;
417        }
418
419        info!("Data directory cleanup completed, app directory and config files preserved");
420        Ok(())
421    }
422
423    /// 执行选择性恢复操作:只恢复指定的目录
424    async fn perform_selective_restore(
425        &self,
426        backup_path: &Path,
427        target_dir: &Path,
428        dirs_to_restore: &[&str],
429    ) -> Result<()> {
430        use flate2::read::GzDecoder;
431        use std::fs::File;
432        use tar::Archive;
433
434        // 确保目标目录存在
435        tokio::fs::create_dir_all(target_dir).await?;
436
437        let backup_path = backup_path.to_path_buf();
438        let target_dir = target_dir.to_path_buf();
439        let dirs_to_restore: Vec<String> = dirs_to_restore.iter().map(|s| s.to_string()).collect();
440
441        // 在后台线程中执行解压操作
442        tokio::task::spawn_blocking(move || {
443            let file = File::open(&backup_path)?;
444            let decoder = GzDecoder::new(file);
445            let mut archive = Archive::new(decoder);
446
447            // 遍历归档中的所有条目
448            for entry in archive.entries()? {
449                let mut entry = entry
450                    .map_err(|e| DuckError::Backup(format!("Failed to read archive entry: {e}")))?;
451
452                // 获取条目路径
453                let entry_path = entry
454                    .path()
455                    .map_err(|e| DuckError::Backup(format!("Failed to get entry path: {e}")))?;
456                let entry_path_str = entry_path.to_string_lossy();
457
458                // 检查是否是我们要恢复的目录
459                let should_restore = dirs_to_restore
460                    .iter()
461                    .any(|dir| entry_path_str.starts_with(&format!("{dir}/")));
462
463                if should_restore {
464                    // 计算解压到的目标路径
465                    let target_path = target_dir.join(&*entry_path);
466
467                    // 确保父目录存在
468                    if let Some(parent) = target_path.parent() {
469                        std::fs::create_dir_all(parent)?;
470                    }
471
472                    // 解压文件
473                    entry.unpack(&target_path).map_err(|e| {
474                        DuckError::Backup(format!(
475                            "Failed to unpack file {}: {e}",
476                            target_path.display()
477                        ))
478                    })?;
479
480                    debug!("Restoring file: {}", target_path.display());
481                }
482            }
483
484            Ok::<(), DuckError>(())
485        })
486        .await??;
487
488        Ok(())
489    }
490
491    /// 执行实际的恢复操作, 可以指定排除的目录,比如回滚恢复的时候,排除 data目录,不会滚数据
492    async fn perform_restore(
493        &self,
494        backup_path: &Path,
495        target_dir: &Path,
496        dirs_to_exculde: &[&str],
497    ) -> Result<()> {
498        // 确保目标目录存在
499        tokio::fs::create_dir_all(target_dir).await?;
500
501        let backup_path = backup_path.to_path_buf();
502        let target_dir = target_dir.to_path_buf();
503        let dirs_to_exclude: Vec<String> = dirs_to_exculde.iter().map(|s| s.to_string()).collect();
504
505        // 在后台线程中执行解压操作
506        tokio::task::spawn_blocking(move || {
507            let file = File::open(&backup_path)?;
508            let decoder = GzDecoder::new(file);
509            let mut archive = Archive::new(decoder);
510
511            let mut debug_dirs = std::collections::HashSet::new();
512
513            // 遍历归档中的所有条目
514            for entry in archive.entries()? {
515                let mut entry = entry
516                    .map_err(|e| DuckError::Backup(format!("Failed to read archive entry: {e}")))?;
517
518                // 获取条目路径
519                let entry_path = entry
520                    .path()
521                    .map_err(|e| DuckError::Backup(format!("Failed to get entry path: {e}")))?;
522                let entry_path_str = entry_path.to_string_lossy();
523
524                // Split path into components
525                let path_components: Vec<&str> = entry_path_str.split('/').collect();
526
527                // Check if this is a directory we want to exclude (first level)
528                let should_exclude = if !path_components.is_empty() {
529                    let first_level_dir = path_components[0];
530                    debug_dirs.insert(first_level_dir.to_string());
531
532                    dirs_to_exclude
533                        .iter()
534                        .any(|dir| dir.as_str() == first_level_dir)
535                } else {
536                    false // Not enough path components, don't exclude
537                };
538
539                if !should_exclude {
540                    // 计算解压到的目标路径
541                    let target_path = target_dir.join(&*entry_path);
542
543                    // 确保父目录存在
544                    if let Some(parent) = target_path.parent() {
545                        std::fs::create_dir_all(parent)?;
546                    }
547
548                    // 解压文件
549                    entry.unpack(&target_path).map_err(|e| {
550                        DuckError::Backup(format!(
551                            "Failed to unpack file {}: {e}",
552                            target_path.display()
553                        ))
554                    })?;
555
556                    debug!("Restoring file: {}", target_path.display());
557                }
558            }
559
560            debug!("Test log, restore directories: {:?}", debug_dirs);
561
562            Ok::<(), DuckError>(())
563        })
564        .await??;
565
566        Ok(())
567    }
568
569    /// 获取所有备份记录
570    pub async fn list_backups(&self) -> Result<Vec<BackupRecord>> {
571        self.database.get_all_backups().await
572    }
573
574    /// 删除备份
575    pub async fn delete_backup(&self, backup_id: i64) -> Result<()> {
576        // 获取备份记录
577        let backup_record = self
578            .database
579            .get_backup_by_id(backup_id)
580            .await?
581            .ok_or_else(|| {
582                DuckError::Backup(format!("Backup record does not exist: {backup_id}"))
583            })?;
584
585        let backup_path = PathBuf::from(&backup_record.file_path);
586
587        // 删除文件
588        if backup_path.exists() {
589            tokio::fs::remove_file(&backup_path).await?;
590            info!("Deleting backup file: {}", backup_path.display());
591        }
592
593        // 从数据库中删除记录
594        self.database.delete_backup_record(backup_id).await?;
595
596        Ok(())
597    }
598
599    /// 检查并迁移备份存储目录
600    pub async fn migrate_storage_directory(&self, new_storage_dir: &Path) -> Result<()> {
601        if new_storage_dir == self.storage_dir {
602            return Ok(()); // 没有变化
603        }
604
605        info!(
606            "Starting to migrate backup storage directory: {} -> {}",
607            self.storage_dir.display(),
608            new_storage_dir.display()
609        );
610
611        // 创建新目录
612        tokio::fs::create_dir_all(new_storage_dir).await?;
613
614        // 获取所有备份记录
615        let backups = self.list_backups().await?;
616
617        for backup in backups {
618            let old_path = PathBuf::from(&backup.file_path);
619            if old_path.exists() {
620                let filename = old_path
621                    .file_name()
622                    .ok_or_else(|| DuckError::Backup("Cannot get backup filename".to_string()))?;
623                let new_path = new_storage_dir.join(filename);
624
625                // 移动文件
626                tokio::fs::rename(&old_path, &new_path).await?;
627                info!(
628                    "Migrating backup file: {} -> {}",
629                    old_path.display(),
630                    new_path.display()
631                );
632
633                // 更新数据库中的路径
634                self.database
635                    .update_backup_file_path(backup.id, new_path.to_string_lossy().to_string())
636                    .await?;
637            }
638        }
639
640        info!("Backup storage directory migration completed");
641        Ok(())
642    }
643
644    /// 获取存储目录
645    pub fn get_storage_dir(&self) -> &Path {
646        &self.storage_dir
647    }
648
649    /// 估算目录大小
650    pub async fn estimate_backup_size(&self, source_dir: &Path) -> Result<u64> {
651        let source_dir = source_dir.to_path_buf();
652
653        let total_size = tokio::task::spawn_blocking(move || {
654            let mut total = 0u64;
655
656            for entry in WalkDir::new(&source_dir).into_iter().flatten() {
657                if entry.path().is_file()
658                    && let Ok(metadata) = entry.metadata()
659                {
660                    total += metadata.len();
661                }
662            }
663
664            total
665        })
666        .await?;
667
668        // 考虑压缩率,估算压缩后大小约为原大小的 30-50%
669        Ok(total_size / 2)
670    }
671}
672
673// 用于将文件添加到归档中
674fn add_file_to_archive(
675    archive: &mut Builder<GzEncoder<File>>,
676    file_path: &Path,
677    base_info: Option<(&Path, &str)>,
678) -> Result<()> {
679    let archive_path = if let Some((base_dir, dir_name)) = base_info {
680        // 文件是目录的一部分,计算相对路径
681        let relative_path = file_path
682            .strip_prefix(base_dir)
683            .map_err(|e| DuckError::Backup(format!("Failed to calculate relative path: {e}")))?;
684
685        // 格式:{dir_name}/{relative_path}
686        if cfg!(windows) {
687            format!(
688                "{}/{}",
689                dir_name,
690                relative_path.display().to_string().replace('\\', "/")
691            )
692        } else {
693            format!("{}/{}", dir_name, relative_path.display())
694        }
695    } else {
696        // 直接处理单个文件,保持原有路径结构
697        let path_str = file_path.to_string_lossy().to_string();
698
699        // 标准化路径分隔符为Unix风格
700        let path_str = if cfg!(windows) {
701            path_str.replace('\\', "/")
702        } else {
703            path_str
704        };
705
706        // 移除路径开头可能的 "./" 前缀
707        if let Some(stripped) = path_str.strip_prefix("./") {
708            stripped.to_string()
709        } else {
710            path_str
711        }
712    };
713
714    debug!(
715        "添加文件到归档: {} -> {}",
716        file_path.display(),
717        archive_path
718    );
719
720    archive
721        .append_path_with_name(file_path, archive_path)
722        .map_err(|e| DuckError::Backup(format!("Failed to add file to archive: {e}")))?;
723
724    Ok(())
725}