Skip to main content

orca_core/backup/
manager.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4use chrono::Utc;
5use tracing::info;
6
7use super::config::{BackupConfig, BackupTarget};
8use super::s3 as s3_backend;
9
10/// Result of a single backup operation.
11#[derive(Debug, Clone)]
12pub struct BackupResult {
13    pub service_name: String,
14    pub timestamp: String,
15    pub size_bytes: u64,
16    pub target: String,
17}
18
19/// Manages backup operations for volumes, configs, and secrets.
20pub struct BackupManager {
21    config: BackupConfig,
22}
23
24impl BackupManager {
25    pub fn new(config: BackupConfig) -> Self {
26        Self { config }
27    }
28
29    /// Backup a service volume directory.
30    pub fn backup_volume(
31        &self,
32        service_name: &str,
33        volume_path: &str,
34        pre_hook: Option<&str>,
35    ) -> Result<BackupResult> {
36        let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
37
38        if let Some(hook) = pre_hook {
39            info!(service = service_name, hook, "Running pre-backup hook");
40            let status = std::process::Command::new("sh")
41                .arg("-c")
42                .arg(hook)
43                .status()
44                .context("Failed to execute pre-backup hook")?;
45            if !status.success() {
46                anyhow::bail!("Pre-backup hook failed: {:?}", status.code());
47            }
48        }
49
50        let archive_name = format!("{service_name}_{timestamp}.tar.gz");
51        let archive_path = std::env::temp_dir().join(&archive_name);
52
53        info!(
54            service = service_name,
55            src = volume_path,
56            "Creating archive"
57        );
58        let status = std::process::Command::new("tar")
59            .args([
60                "-czf",
61                archive_path.to_str().unwrap_or(""),
62                "-C",
63                volume_path,
64                ".",
65            ])
66            .status()
67            .context("Failed to create tar archive")?;
68        if !status.success() {
69            anyhow::bail!("tar failed: {:?}", status.code());
70        }
71
72        let size_bytes = std::fs::metadata(&archive_path)
73            .map(|m| m.len())
74            .unwrap_or(0);
75        let mut target_desc = String::new();
76        for t in &self.config.targets {
77            target_desc = self.store(&archive_path, t, &archive_name)?;
78        }
79        let _ = std::fs::remove_file(&archive_path);
80
81        Ok(BackupResult {
82            service_name: service_name.to_string(),
83            timestamp,
84            size_bytes,
85            target: target_desc,
86        })
87    }
88
89    /// Backup a single file to all targets.
90    pub fn backup_file(&self, name: &str, path: &Path) -> Result<()> {
91        let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
92        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("bak");
93        let backup_name = format!("{name}_{timestamp}.{ext}");
94        for t in &self.config.targets {
95            self.store(path, t, &backup_name)?;
96        }
97        Ok(())
98    }
99
100    fn store(&self, data_path: &Path, target: &BackupTarget, name: &str) -> Result<String> {
101        match target {
102            BackupTarget::Local { path } => {
103                let dest_dir = Path::new(path);
104                std::fs::create_dir_all(dest_dir)
105                    .with_context(|| format!("create backup dir: {path}"))?;
106                let dest = dest_dir.join(name);
107                std::fs::copy(data_path, &dest)
108                    .with_context(|| format!("copy to {}", dest.display()))?;
109                info!(dest = %dest.display(), "Stored backup locally");
110                Ok(format!("local:{path}"))
111            }
112            t @ BackupTarget::S3 { bucket, .. } => {
113                s3_backend::upload(data_path, t, name)?;
114                Ok(format!("s3://{bucket}"))
115            }
116        }
117    }
118
119    /// List backups in a target.
120    pub fn list_backups(&self, target: &BackupTarget) -> Result<Vec<String>> {
121        match target {
122            BackupTarget::Local { path } => {
123                let dir = Path::new(path);
124                if !dir.exists() {
125                    return Ok(vec![]);
126                }
127                let mut entries = Vec::new();
128                for entry in std::fs::read_dir(dir)? {
129                    if let Some(name) = entry?.file_name().to_str() {
130                        entries.push(name.to_string());
131                    }
132                }
133                entries.sort();
134                Ok(entries)
135            }
136            t @ BackupTarget::S3 { .. } => s3_backend::list_objects(t),
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn backup_file_to_local() {
147        let tmp = tempfile::tempdir().unwrap();
148        let target_dir = tmp.path().join("backups");
149        let config = BackupConfig {
150            schedule: None,
151            retention_days: 7,
152            targets: vec![BackupTarget::Local {
153                path: target_dir.to_str().unwrap().to_string(),
154            }],
155        };
156        let mgr = BackupManager::new(config);
157        let src = tmp.path().join("test.json");
158        std::fs::write(&src, r#"{"key":"value"}"#).unwrap();
159        mgr.backup_file("secrets", &src).unwrap();
160        let backups = std::fs::read_dir(&target_dir).unwrap().count();
161        assert_eq!(backups, 1);
162    }
163}