Skip to main content

ryra_core/
backup.rs

1//! Backup planning. Pure functions that take service install state +
2//! the user's backup config and produce typed plans the CLI executes.
3//!
4//! What lives here:
5//! - [`BackupRunPlan`]: everything the CLI needs to push one service's
6//!   data to the configured restic repository.
7//! - [`BackupRestorePlan`]: same shape for the reverse operation.
8//! - [`plan_backup_run`] / [`plan_backup_restore`]: the planners.
9//!
10//! What does *not* live here: spawning the `restic` subprocess, running
11//! hook scripts, or any other side effect. The CLI layer owns those.
12//! Keeping the planner pure means it round-trips cleanly in tests
13//! against a tempdir without needing restic on the test runner.
14
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18use sha2::{Digest, Sha256};
19
20use crate::config::schema::{BackupBackend, Config};
21use crate::data::classify::classify_home_dir;
22use crate::error::{Error, Result};
23use crate::metadata::{Metadata, load_metadata};
24use crate::paths::service_home;
25use crate::registry;
26use crate::registry::service_def::{BackupConfig, ServiceDef};
27
28const SERVICE_TOML_FILENAME: &str = "service.toml";
29
30/// Concrete instructions for backing up one installed service.
31///
32/// The CLI consumes this by:
33/// 1. Running every `pre_backup_hook` script in order.
34/// 2. Spawning `restic backup` with `repo`, `password` (via
35///    `RESTIC_PASSWORD` env), `env` set on the child, `--tag` for each
36///    string in `tags`, and `--exclude` for each string in `excludes`,
37///    with `paths` as the positional arguments.
38/// 3. Running every `post_backup_hook` (even if step 2 failed —
39///    failure-cleanup matters; see [`PlanHook::Cleanup`]).
40#[derive(Debug, Clone)]
41pub struct BackupRunPlan {
42    pub service_name: String,
43    pub service_home: PathBuf,
44    pub repo: String,
45    pub password: String,
46    pub env: BTreeMap<String, String>,
47    pub tags: Vec<String>,
48    pub paths: Vec<PathBuf>,
49    pub excludes: Vec<String>,
50    pub pre_backup_hook: Option<PathBuf>,
51    pub post_backup_hook: Option<PathBuf>,
52}
53
54/// Instructions for restoring one installed service from a specific
55/// restic snapshot.
56#[derive(Debug, Clone)]
57pub struct BackupRestorePlan {
58    pub service_name: String,
59    pub service_home: PathBuf,
60    pub repo: String,
61    pub password: String,
62    pub env: BTreeMap<String, String>,
63    /// `latest` to grab the newest snapshot, or a specific restic
64    /// snapshot id (hex prefix) when the user passed `--at <id>`.
65    pub snapshot: String,
66    pub pre_restore_hook: Option<PathBuf>,
67    pub post_restore_hook: Option<PathBuf>,
68}
69
70/// Plan a `ryra backup run <service>` invocation. Errors loudly when:
71/// - the service isn't installed,
72/// - its install metadata didn't opt into backups (`--backup` wasn't
73///   passed at `ryra add`),
74/// - the user hasn't run `ryra backup configure` yet,
75/// - the service author hasn't declared backup support (defensive —
76///   the install-time check should have caught this earlier, but a
77///   manifest change between install and backup is possible).
78pub fn plan_backup_run(
79    service_name: &str,
80    config: &Config,
81    repo_dir: &Path,
82) -> Result<BackupRunPlan> {
83    let metadata = load_install_metadata(service_name)?;
84    if !metadata.backup_enabled {
85        return Err(Error::BackupNotEnabled(service_name.to_string()));
86    }
87    let settings = config
88        .backup
89        .as_ref()
90        .ok_or(Error::BackupRepoNotConfigured)?;
91
92    let svc = registry::find_service(repo_dir, service_name)?;
93    if !svc.def.integrations.backup {
94        return Err(Error::BackupNotSupported(service_name.to_string()));
95    }
96
97    let home = service_home(service_name)?;
98    let (paths, excludes) = resolve_paths(&svc.def, &home)?;
99
100    let manifest_sha = manifest_sha256(&svc.service_dir);
101    let mut tags = vec![format!("service:{service_name}")];
102    tags.push(format!("manifest_sha:{}", &manifest_sha[..16]));
103
104    let backup = svc.def.backup.as_ref();
105    let pre = resolve_hook(
106        backup.and_then(|b| b.pre_backup.as_deref()),
107        &home,
108        "backup-pre.sh",
109    );
110    let post = resolve_hook(
111        backup.and_then(|b| b.post_backup.as_deref()),
112        &home,
113        "backup-post.sh",
114    );
115
116    Ok(BackupRunPlan {
117        service_name: service_name.to_string(),
118        service_home: home,
119        repo: settings.backend.restic_repo(),
120        password: settings.password.clone(),
121        env: backend_env_map(&settings.backend),
122        tags,
123        paths,
124        excludes,
125        pre_backup_hook: pre,
126        post_backup_hook: post,
127    })
128}
129
130/// Plan a `ryra backup restore <service>` invocation.
131///
132/// `snapshot` is either `latest` (newest snapshot tagged with this
133/// service) or an explicit restic snapshot id. The CLI resolves the
134/// actual id by querying restic; this planner stays pure and just
135/// passes the user's choice through.
136pub fn plan_backup_restore(
137    service_name: &str,
138    snapshot: &str,
139    config: &Config,
140    repo_dir: &Path,
141) -> Result<BackupRestorePlan> {
142    let metadata = load_install_metadata(service_name)?;
143    if !metadata.backup_enabled {
144        return Err(Error::BackupNotEnabled(service_name.to_string()));
145    }
146    let settings = config
147        .backup
148        .as_ref()
149        .ok_or(Error::BackupRepoNotConfigured)?;
150
151    let svc = registry::find_service(repo_dir, service_name)?;
152    let home = service_home(service_name)?;
153
154    let backup = svc.def.backup.as_ref();
155    let pre = resolve_hook(
156        backup.and_then(|b| b.pre_restore.as_deref()),
157        &home,
158        "restore-pre.sh",
159    );
160    let post = resolve_hook(
161        backup.and_then(|b| b.post_restore.as_deref()),
162        &home,
163        "restore-post.sh",
164    );
165
166    Ok(BackupRestorePlan {
167        service_name: service_name.to_string(),
168        service_home: home,
169        repo: settings.backend.restic_repo(),
170        password: settings.password.clone(),
171        env: backend_env_map(&settings.backend),
172        snapshot: snapshot.to_string(),
173        pre_restore_hook: pre,
174        post_restore_hook: post,
175    })
176}
177
178/// List installed services that have `backup_enabled = true` in their
179/// metadata. The CLI's `ryra backup run` (no service argument) uses
180/// this to iterate every enabled install.
181pub fn list_backup_enabled() -> Result<Vec<String>> {
182    let root = crate::paths::service_data_root()?;
183    if !root.is_dir() {
184        return Ok(Vec::new());
185    }
186    let mut out = Vec::new();
187    for entry in std::fs::read_dir(&root).map_err(|source| Error::FileRead {
188        path: root.clone(),
189        source,
190    })? {
191        let entry = entry.map_err(|source| Error::FileRead {
192            path: root.clone(),
193            source,
194        })?;
195        let name = match entry.file_name().to_str() {
196            Some(s) => s.to_string(),
197            None => continue,
198        };
199        if let Some(meta) = load_metadata(&name)?
200            && meta.backup_enabled
201        {
202            out.push(name);
203        }
204    }
205    out.sort();
206    Ok(out)
207}
208
209fn load_install_metadata(service_name: &str) -> Result<Metadata> {
210    load_metadata(service_name)?.ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))
211}
212
213/// Resolve the set of absolute paths to feed restic, plus the list of
214/// `--exclude` patterns.
215///
216/// Two routes:
217/// - Explicit `[backup].paths`: trust the manifest, resolve each
218///   entry against the service home.
219/// - No explicit paths: ask the classifier "what's data here?" — that
220///   covers every top-level child not in the install manifest (the
221///   `data/` directory, the `db-data/` directory, anything the user
222///   has dropped in). Also include `.backup/` if the manifest declared
223///   any pre_backup hook, since that's the convention for dumping.
224fn resolve_paths(def: &ServiceDef, home: &Path) -> Result<(Vec<PathBuf>, Vec<String>)> {
225    let backup = def.backup.as_ref();
226    let excludes: Vec<String> = backup.map(|b| b.exclude.clone()).unwrap_or_default();
227
228    if let Some(b) = backup
229        && !b.paths.is_empty()
230    {
231        let abs: Vec<PathBuf> = b.paths.iter().map(|p| home.join(p)).collect();
232        return Ok((abs, excludes));
233    }
234
235    // Default: classifier-derived data paths.
236    let (data, _ephemeral) = classify_home_dir(home)?;
237    let mut paths: Vec<PathBuf> = data;
238
239    // If a hook is declared, it writes to .backup/ — include it if
240    // present at backup time. We add it conditionally rather than
241    // unconditionally so a service that never created the dir doesn't
242    // make restic complain about a missing path.
243    if backup.is_some_and(has_any_backup_hook) {
244        let dump_dir = home.join(".backup");
245        if dump_dir.exists() && !paths.iter().any(|p| p == &dump_dir) {
246            paths.push(dump_dir);
247        }
248    }
249
250    paths.sort();
251    Ok((paths, excludes))
252}
253
254fn has_any_backup_hook(b: &BackupConfig) -> bool {
255    b.pre_backup.is_some() || b.post_backup.is_some()
256}
257
258fn hook_path(home: &Path, filename: &str) -> PathBuf {
259    home.join("configs").join("scripts").join(filename)
260}
261
262/// Decide which hook script (if any) to invoke for a given lifecycle
263/// phase. Priority:
264/// 1. Explicit `[backup].pre_backup` (or sibling) in service.toml.
265/// 2. Convention: `configs/scripts/<phase>.sh` on disk.
266/// 3. None — phase is a no-op.
267///
268/// The convention path means a typical service.toml's `[backup]`
269/// section is a single `paths = [...]` line; the four hook scripts
270/// are auto-discovered when their conventional names are present in
271/// `configs/scripts/`, and authors never have to repeat the
272/// filenames in the manifest.
273fn resolve_hook(explicit: Option<&str>, home: &Path, conventional: &str) -> Option<PathBuf> {
274    if let Some(name) = explicit {
275        return Some(hook_path(home, name));
276    }
277    let conv = hook_path(home, conventional);
278    if conv.exists() { Some(conv) } else { None }
279}
280
281fn backend_env_map(backend: &BackupBackend) -> BTreeMap<String, String> {
282    backend
283        .env()
284        .into_iter()
285        .map(|(k, v)| (k.to_string(), v))
286        .collect()
287}
288
289/// Hex SHA256 of the service's `service.toml`. Used as the
290/// `manifest_sha:` tag on each snapshot so a future restore can detect
291/// version skew between the snapshot and the currently-installed
292/// service definition.
293///
294/// Falls back to an all-zero hash if the file can't be read — the
295/// caller's higher-level error handling will already have failed for
296/// other reasons, and a sentinel hash is more useful than panicking.
297pub fn manifest_sha256(service_dir: &Path) -> String {
298    let path = service_dir.join(SERVICE_TOML_FILENAME);
299    let bytes = match std::fs::read(&path) {
300        Ok(b) => b,
301        Err(_) => return "0".repeat(64),
302    };
303    let mut hasher = Sha256::new();
304    hasher.update(&bytes);
305    let digest = hasher.finalize();
306    hex_encode(&digest)
307}
308
309fn hex_encode(bytes: &[u8]) -> String {
310    const HEX: &[u8; 16] = b"0123456789abcdef";
311    let mut s = String::with_capacity(bytes.len() * 2);
312    for b in bytes {
313        s.push(HEX[(b >> 4) as usize] as char);
314        s.push(HEX[(b & 0xf) as usize] as char);
315    }
316    s
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::config::schema::{BackupBackend, BackupSettings};
323    use crate::registry::service_def::{
324        Arch, HttpsRequirement, IntegrationFlags, PortDef, ServiceDef, ServiceMeta,
325    };
326
327    fn def_with_backup(backup_section: Option<BackupConfig>) -> ServiceDef {
328        ServiceDef {
329            service: ServiceMeta {
330                name: "demo".into(),
331                description: "demo".into(),
332                url: None,
333                kind: Default::default(),
334                architecture: vec![Arch::Amd64, Arch::Arm64],
335                https: HttpsRequirement::default(),
336            },
337            requirements: None,
338            ports: vec![PortDef {
339                name: "http".into(),
340                container_port: 80,
341                host_port: None,
342                protocol: Default::default(),
343            }],
344            env: vec![],
345            env_groups: vec![],
346            requires: vec![],
347            mappings: Default::default(),
348            integrations: IntegrationFlags {
349                backup: backup_section.is_some(),
350                ..Default::default()
351            },
352            capabilities: Default::default(),
353            backup: backup_section,
354        }
355    }
356
357    #[test]
358    fn resolve_paths_uses_classifier_when_paths_empty() {
359        let dir = tempfile::tempdir().unwrap();
360        let home = dir.path();
361        std::fs::create_dir(home.join("data")).unwrap();
362        std::fs::create_dir(home.join("cache")).unwrap();
363        // No manifest — classifier treats everything as data.
364        let def = def_with_backup(Some(BackupConfig::default()));
365        let (paths, excludes) = resolve_paths(&def.clone(), home).unwrap();
366        let names: Vec<String> = paths
367            .iter()
368            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
369            .collect();
370        assert!(names.contains(&"data".to_string()), "got {names:?}");
371        assert!(names.contains(&"cache".to_string()), "got {names:?}");
372        assert!(excludes.is_empty());
373    }
374
375    #[test]
376    fn resolve_paths_honours_explicit_list() {
377        let dir = tempfile::tempdir().unwrap();
378        let home = dir.path();
379        let def = def_with_backup(Some(BackupConfig {
380            paths: vec!["data/uploads".into(), ".backup/db.sql".into()],
381            exclude: vec!["data/uploads/cache".into()],
382            ..Default::default()
383        }));
384        let (paths, excludes) = resolve_paths(&def, home).unwrap();
385        assert_eq!(
386            paths,
387            vec![home.join("data/uploads"), home.join(".backup/db.sql")]
388        );
389        assert_eq!(excludes, vec!["data/uploads/cache"]);
390    }
391
392    #[test]
393    fn resolve_paths_includes_dot_backup_when_hook_declared() {
394        let dir = tempfile::tempdir().unwrap();
395        let home = dir.path();
396        std::fs::create_dir(home.join("data")).unwrap();
397        std::fs::create_dir(home.join(".backup")).unwrap();
398        // Hook declared but no explicit paths → classifier output
399        // plus .backup/.
400        let def = def_with_backup(Some(BackupConfig {
401            pre_backup: Some("dump.sh".into()),
402            ..Default::default()
403        }));
404        let (paths, _) = resolve_paths(&def, home).unwrap();
405        let names: Vec<String> = paths
406            .iter()
407            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
408            .collect();
409        assert!(names.contains(&".backup".to_string()), "got {names:?}");
410        assert!(names.contains(&"data".to_string()), "got {names:?}");
411    }
412
413    #[test]
414    fn hook_path_resolves_under_configs_scripts() {
415        let home = PathBuf::from("/x/y");
416        assert_eq!(
417            hook_path(&home, "backup-pre.sh"),
418            PathBuf::from("/x/y/configs/scripts/backup-pre.sh")
419        );
420    }
421
422    #[test]
423    fn resolve_hook_prefers_explicit_over_convention() {
424        let dir = tempfile::tempdir().unwrap();
425        let home = dir.path();
426        // Both the conventional and a custom-named file exist; the
427        // explicit field wins.
428        let scripts = home.join("configs").join("scripts");
429        std::fs::create_dir_all(&scripts).unwrap();
430        std::fs::write(scripts.join("backup-pre.sh"), "#!/bin/sh\n").unwrap();
431        std::fs::write(scripts.join("custom.sh"), "#!/bin/sh\n").unwrap();
432        let resolved = resolve_hook(Some("custom.sh"), home, "backup-pre.sh");
433        assert_eq!(resolved.unwrap().file_name().unwrap(), "custom.sh");
434    }
435
436    #[test]
437    fn resolve_hook_falls_back_to_convention_when_present() {
438        let dir = tempfile::tempdir().unwrap();
439        let home = dir.path();
440        let scripts = home.join("configs").join("scripts");
441        std::fs::create_dir_all(&scripts).unwrap();
442        std::fs::write(scripts.join("backup-pre.sh"), "#!/bin/sh\n").unwrap();
443        let resolved = resolve_hook(None, home, "backup-pre.sh");
444        assert_eq!(resolved.unwrap().file_name().unwrap(), "backup-pre.sh");
445    }
446
447    #[test]
448    fn resolve_hook_returns_none_when_no_script_exists() {
449        let dir = tempfile::tempdir().unwrap();
450        // No configs/scripts/ at all → no hook to run.
451        assert!(resolve_hook(None, dir.path(), "backup-pre.sh").is_none());
452    }
453
454    #[test]
455    fn manifest_sha256_changes_with_content() {
456        let a = tempfile::tempdir().unwrap();
457        let b = tempfile::tempdir().unwrap();
458        std::fs::write(a.path().join("service.toml"), "v1").unwrap();
459        std::fs::write(b.path().join("service.toml"), "v2").unwrap();
460        assert_ne!(manifest_sha256(a.path()), manifest_sha256(b.path()));
461    }
462
463    #[test]
464    fn manifest_sha256_stable_for_identical_content() {
465        let a = tempfile::tempdir().unwrap();
466        let b = tempfile::tempdir().unwrap();
467        std::fs::write(a.path().join("service.toml"), "same").unwrap();
468        std::fs::write(b.path().join("service.toml"), "same").unwrap();
469        assert_eq!(manifest_sha256(a.path()), manifest_sha256(b.path()));
470    }
471
472    #[test]
473    fn manifest_sha256_returns_zero_hash_on_missing_file() {
474        let dir = tempfile::tempdir().unwrap();
475        assert_eq!(manifest_sha256(dir.path()), "0".repeat(64));
476    }
477
478    #[test]
479    fn backend_env_map_round_trips_aws_creds() {
480        let settings = BackupSettings {
481            password: "p".into(),
482            backend: BackupBackend::S3 {
483                endpoint: "http://h:9000".into(),
484                bucket: "b".into(),
485                access_key_id: "id".into(),
486                secret_access_key: "secret".into(),
487                prefix: None,
488            },
489        };
490        let env = backend_env_map(&settings.backend);
491        assert_eq!(env.get("AWS_ACCESS_KEY_ID"), Some(&"id".to_string()));
492        assert_eq!(
493            env.get("AWS_SECRET_ACCESS_KEY"),
494            Some(&"secret".to_string())
495        );
496    }
497}