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::ConfigPaths;
21use crate::config::schema::{BackupBackend, Config};
22use crate::error::{Error, Result};
23use crate::metadata::{Metadata, load_metadata};
24use crate::paths::service_home;
25use crate::registry;
26use crate::registry::service_def::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 (mut paths, excludes) = resolve_paths(&svc.def, &home)?;
99
100    // Every snapshot also carries the global `preferences.toml` (repo
101    // creds, SMTP, auth, generated secrets). It's tiny and restic dedups
102    // it across services, so the cost is ~nothing — and it means any
103    // single service snapshot is enough to restore the global config.
104    let prefs = ConfigPaths::resolve()?.config_file;
105    if prefs.exists() {
106        paths.push(prefs);
107    }
108
109    let manifest_sha = manifest_sha256(&svc.service_dir);
110    let mut tags = vec![format!("service:{service_name}")];
111    tags.push(format!("manifest_sha:{}", &manifest_sha[..16]));
112
113    let backup = svc.def.backup.as_ref();
114    let pre = resolve_hook(
115        backup.and_then(|b| b.pre_backup.as_deref()),
116        &home,
117        "backup-pre.sh",
118    );
119    let post = resolve_hook(
120        backup.and_then(|b| b.post_backup.as_deref()),
121        &home,
122        "backup-post.sh",
123    );
124
125    Ok(BackupRunPlan {
126        service_name: service_name.to_string(),
127        service_home: home,
128        repo: settings.backend.restic_repo(),
129        password: settings.password.clone(),
130        env: backend_env_map(&settings.backend),
131        tags,
132        paths,
133        excludes,
134        pre_backup_hook: pre,
135        post_backup_hook: post,
136    })
137}
138
139/// Plan a `ryra backup restore <service>` invocation.
140///
141/// `snapshot` is either `latest` (newest snapshot tagged with this
142/// service) or an explicit restic snapshot id. The CLI resolves the
143/// actual id by querying restic; this planner stays pure and just
144/// passes the user's choice through.
145pub fn plan_backup_restore(
146    service_name: &str,
147    snapshot: &str,
148    config: &Config,
149    repo_dir: &Path,
150) -> Result<BackupRestorePlan> {
151    let metadata = load_install_metadata(service_name)?;
152    if !metadata.backup_enabled {
153        return Err(Error::BackupNotEnabled(service_name.to_string()));
154    }
155    let settings = config
156        .backup
157        .as_ref()
158        .ok_or(Error::BackupRepoNotConfigured)?;
159
160    let svc = registry::find_service(repo_dir, service_name)?;
161    let home = service_home(service_name)?;
162
163    let backup = svc.def.backup.as_ref();
164    let pre = resolve_hook(
165        backup.and_then(|b| b.pre_restore.as_deref()),
166        &home,
167        "restore-pre.sh",
168    );
169    let post = resolve_hook(
170        backup.and_then(|b| b.post_restore.as_deref()),
171        &home,
172        "restore-post.sh",
173    );
174
175    Ok(BackupRestorePlan {
176        service_name: service_name.to_string(),
177        service_home: home,
178        repo: settings.backend.restic_repo(),
179        password: settings.password.clone(),
180        env: backend_env_map(&settings.backend),
181        snapshot: snapshot.to_string(),
182        pre_restore_hook: pre,
183        post_restore_hook: post,
184    })
185}
186
187/// List installed services that have `backup_enabled = true` in their
188/// metadata. The CLI's `ryra backup run` (no service argument) uses
189/// this to iterate every enabled install.
190pub fn list_backup_enabled() -> Result<Vec<String>> {
191    let root = crate::paths::service_data_root()?;
192    if !root.is_dir() {
193        return Ok(Vec::new());
194    }
195    let mut out = Vec::new();
196    for entry in std::fs::read_dir(&root).map_err(|source| Error::FileRead {
197        path: root.clone(),
198        source,
199    })? {
200        let entry = entry.map_err(|source| Error::FileRead {
201            path: root.clone(),
202            source,
203        })?;
204        let name = match entry.file_name().to_str() {
205            Some(s) => s.to_string(),
206            None => continue,
207        };
208        if let Some(meta) = load_metadata(&name)?
209            && meta.backup_enabled
210        {
211            out.push(name);
212        }
213    }
214    out.sort();
215    Ok(out)
216}
217
218fn load_install_metadata(service_name: &str) -> Result<Metadata> {
219    load_metadata(service_name)?.ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))
220}
221
222/// Resolve the set of absolute paths to feed restic, plus the list of
223/// `--exclude` patterns.
224///
225/// Two routes:
226/// - Explicit `[backup].paths`: trust the manifest, resolve each
227///   entry against the service home.
228/// - No explicit paths: ask the classifier "what's data here?" — that
229///   covers every top-level child not in the install manifest (the
230///   `data/` directory, the `db-data/` directory, anything the user
231///   has dropped in). Also include `.backup/` if the manifest declared
232///   any pre_backup hook, since that's the convention for dumping.
233fn resolve_paths(def: &ServiceDef, home: &Path) -> Result<(Vec<PathBuf>, Vec<String>)> {
234    let backup = def.backup.as_ref();
235    let excludes: Vec<String> = backup.map(|b| b.exclude.clone()).unwrap_or_default();
236
237    // Whole-folder backup: capture the entire service home in one path.
238    // This carries config (`.env`, `metadata.toml`, quadlets, rendered
239    // configs) alongside data, so a restore reconstructs the install
240    // without re-running `ryra add` — that's the difference between
241    // "restore and go" and a hand rebuild.
242    //
243    // Database consistency is the hooks' job, not the path list's:
244    //  - dump services (`backup-pre.sh` → mariadb-dump/pg_dump into
245    //    `.backup/`) list their *live* DB dir in `[backup].exclude` so
246    //    the consistent dump is authoritative, not the changing files;
247    //  - cold-stop services stop the DB before the snapshot, so its dir
248    //    is already consistent and is captured as part of the folder.
249    //
250    // `exclude` also drops regenerable caches. An explicit
251    // `[backup].paths` still narrows the capture for the rare service
252    // that needs it, but the default — and the recommendation — is the
253    // whole folder.
254    if let Some(b) = backup
255        && !b.paths.is_empty()
256    {
257        // A curated `paths` list keeps regenerable junk (thumbnails,
258        // transcodes) out of the snapshot — honour it for *data*, but
259        // always add the config artifacts so a restore can still
260        // reconstruct the install without `ryra add`.
261        let mut abs: Vec<PathBuf> = b.paths.iter().map(|p| home.join(p)).collect();
262        abs.extend(config_artifacts(home));
263        abs.sort();
264        abs.dedup();
265        return Ok((abs, excludes));
266    }
267
268    Ok((vec![home.to_path_buf()], excludes))
269}
270
271/// The config artifacts that must travel with every backup so a restore
272/// reconstructs the install without re-running `ryra add`: the generated
273/// `.env`, `metadata.toml`, the render manifest, the rendered `configs/`
274/// tree, and the quadlet unit files. Only existing paths are returned so
275/// the list feeds straight to restic. (Services with no explicit
276/// `paths` capture the whole folder, which already covers all of these.)
277fn config_artifacts(home: &Path) -> Vec<PathBuf> {
278    let mut out = Vec::new();
279    for f in [".env", "metadata.toml", "service.manifest"] {
280        let p = home.join(f);
281        if p.exists() {
282            out.push(p);
283        }
284    }
285    let configs = home.join("configs");
286    if configs.is_dir() {
287        out.push(configs);
288    }
289    if let Ok(entries) = std::fs::read_dir(home) {
290        for entry in entries.flatten() {
291            let name = entry.file_name();
292            let n = name.to_string_lossy();
293            if n.ends_with(".container") || n.ends_with(".network") || n.ends_with(".volume") {
294                out.push(entry.path());
295            }
296        }
297    }
298    out
299}
300
301fn hook_path(home: &Path, filename: &str) -> PathBuf {
302    home.join("configs").join("scripts").join(filename)
303}
304
305/// Decide which hook script (if any) to invoke for a given lifecycle
306/// phase. Priority:
307/// 1. Explicit `[backup].pre_backup` (or sibling) in service.toml.
308/// 2. Convention: `configs/scripts/<phase>.sh` on disk.
309/// 3. None — phase is a no-op.
310///
311/// The convention path means a typical service.toml's `[backup]`
312/// section is a single `paths = [...]` line; the four hook scripts
313/// are auto-discovered when their conventional names are present in
314/// `configs/scripts/`, and authors never have to repeat the
315/// filenames in the manifest.
316fn resolve_hook(explicit: Option<&str>, home: &Path, conventional: &str) -> Option<PathBuf> {
317    if let Some(name) = explicit {
318        return Some(hook_path(home, name));
319    }
320    let conv = hook_path(home, conventional);
321    if conv.exists() { Some(conv) } else { None }
322}
323
324fn backend_env_map(backend: &BackupBackend) -> BTreeMap<String, String> {
325    backend
326        .env()
327        .into_iter()
328        .map(|(k, v)| (k.to_string(), v))
329        .collect()
330}
331
332/// Hex SHA256 of the service's `service.toml`. Used as the
333/// `manifest_sha:` tag on each snapshot so a future restore can detect
334/// version skew between the snapshot and the currently-installed
335/// service definition.
336///
337/// Falls back to an all-zero hash if the file can't be read — the
338/// caller's higher-level error handling will already have failed for
339/// other reasons, and a sentinel hash is more useful than panicking.
340pub fn manifest_sha256(service_dir: &Path) -> String {
341    let path = service_dir.join(SERVICE_TOML_FILENAME);
342    let bytes = match std::fs::read(&path) {
343        Ok(b) => b,
344        Err(_) => return "0".repeat(64),
345    };
346    let mut hasher = Sha256::new();
347    hasher.update(&bytes);
348    let digest = hasher.finalize();
349    hex_encode(&digest)
350}
351
352fn hex_encode(bytes: &[u8]) -> String {
353    const HEX: &[u8; 16] = b"0123456789abcdef";
354    let mut s = String::with_capacity(bytes.len() * 2);
355    for b in bytes {
356        s.push(HEX[(b >> 4) as usize] as char);
357        s.push(HEX[(b & 0xf) as usize] as char);
358    }
359    s
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::config::schema::{BackupBackend, BackupSettings};
366    use crate::registry::service_def::{
367        Arch, BackupConfig, HttpsRequirement, IntegrationFlags, PortDef, ServiceDef, ServiceMeta,
368    };
369
370    fn def_with_backup(backup_section: Option<BackupConfig>) -> ServiceDef {
371        ServiceDef {
372            service: ServiceMeta {
373                name: "demo".into(),
374                description: "demo".into(),
375                url: None,
376                kind: Default::default(),
377                architecture: vec![Arch::Amd64, Arch::Arm64],
378                https: HttpsRequirement::default(),
379                runtime: Default::default(),
380                run: None,
381                build: None,
382            },
383            requirements: None,
384            ports: vec![PortDef {
385                name: "http".into(),
386                container_port: 80,
387                host_port: None,
388                protocol: Default::default(),
389                tailscale_https: None,
390            }],
391            env: vec![],
392            env_groups: vec![],
393            requires: vec![],
394            mappings: Default::default(),
395            integrations: IntegrationFlags {
396                backup: backup_section.is_some(),
397                ..Default::default()
398            },
399            capabilities: Default::default(),
400            backup: backup_section,
401        }
402    }
403
404    #[test]
405    fn resolve_paths_whole_folder_when_paths_empty() {
406        let dir = tempfile::tempdir().unwrap();
407        let home = dir.path();
408        // No explicit `paths` → capture the whole service folder.
409        let def = def_with_backup(Some(BackupConfig::default()));
410        let (paths, excludes) = resolve_paths(&def.clone(), home).unwrap();
411        assert_eq!(paths, vec![home.to_path_buf()]);
412        assert!(excludes.is_empty());
413    }
414
415    #[test]
416    fn resolve_paths_explicit_list_plus_config_artifacts() {
417        let dir = tempfile::tempdir().unwrap();
418        let home = dir.path();
419        // Config artifacts present in the home travel with the data.
420        std::fs::write(home.join(".env"), "x").unwrap();
421        std::fs::write(home.join("metadata.toml"), "x").unwrap();
422        let def = def_with_backup(Some(BackupConfig {
423            paths: vec!["data/uploads".into(), ".backup/db.sql".into()],
424            exclude: vec!["data/uploads/cache".into()],
425            ..Default::default()
426        }));
427        let (paths, excludes) = resolve_paths(&def, home).unwrap();
428        // Curated data paths honoured...
429        assert!(paths.contains(&home.join("data/uploads")), "got {paths:?}");
430        assert!(
431            paths.contains(&home.join(".backup/db.sql")),
432            "got {paths:?}"
433        );
434        // ...and config artifacts added so a restore can rebuild the install.
435        assert!(paths.contains(&home.join(".env")), "got {paths:?}");
436        assert!(paths.contains(&home.join("metadata.toml")), "got {paths:?}");
437        assert_eq!(excludes, vec!["data/uploads/cache"]);
438    }
439
440    #[test]
441    fn config_artifacts_collects_env_metadata_quadlets_configs() {
442        let dir = tempfile::tempdir().unwrap();
443        let home = dir.path();
444        std::fs::write(home.join(".env"), "x").unwrap();
445        std::fs::write(home.join("metadata.toml"), "x").unwrap();
446        std::fs::write(home.join("service.manifest"), "x").unwrap();
447        std::fs::write(home.join("demo.container"), "x").unwrap();
448        std::fs::write(home.join("demo.network"), "x").unwrap();
449        std::fs::create_dir(home.join("configs")).unwrap();
450        let names: Vec<String> = config_artifacts(home)
451            .iter()
452            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
453            .collect();
454        for want in [
455            ".env",
456            "metadata.toml",
457            "service.manifest",
458            "demo.container",
459            "demo.network",
460            "configs",
461        ] {
462            assert!(
463                names.contains(&want.to_string()),
464                "{want} missing: {names:?}"
465            );
466        }
467    }
468
469    #[test]
470    fn hook_path_resolves_under_configs_scripts() {
471        let home = PathBuf::from("/x/y");
472        assert_eq!(
473            hook_path(&home, "backup-pre.sh"),
474            PathBuf::from("/x/y/configs/scripts/backup-pre.sh")
475        );
476    }
477
478    #[test]
479    fn resolve_hook_prefers_explicit_over_convention() {
480        let dir = tempfile::tempdir().unwrap();
481        let home = dir.path();
482        // Both the conventional and a custom-named file exist; the
483        // explicit field wins.
484        let scripts = home.join("configs").join("scripts");
485        std::fs::create_dir_all(&scripts).unwrap();
486        std::fs::write(scripts.join("backup-pre.sh"), "#!/bin/sh\n").unwrap();
487        std::fs::write(scripts.join("custom.sh"), "#!/bin/sh\n").unwrap();
488        let resolved = resolve_hook(Some("custom.sh"), home, "backup-pre.sh");
489        assert_eq!(resolved.unwrap().file_name().unwrap(), "custom.sh");
490    }
491
492    #[test]
493    fn resolve_hook_falls_back_to_convention_when_present() {
494        let dir = tempfile::tempdir().unwrap();
495        let home = dir.path();
496        let scripts = home.join("configs").join("scripts");
497        std::fs::create_dir_all(&scripts).unwrap();
498        std::fs::write(scripts.join("backup-pre.sh"), "#!/bin/sh\n").unwrap();
499        let resolved = resolve_hook(None, home, "backup-pre.sh");
500        assert_eq!(resolved.unwrap().file_name().unwrap(), "backup-pre.sh");
501    }
502
503    #[test]
504    fn resolve_hook_returns_none_when_no_script_exists() {
505        let dir = tempfile::tempdir().unwrap();
506        // No configs/scripts/ at all → no hook to run.
507        assert!(resolve_hook(None, dir.path(), "backup-pre.sh").is_none());
508    }
509
510    #[test]
511    fn manifest_sha256_changes_with_content() {
512        let a = tempfile::tempdir().unwrap();
513        let b = tempfile::tempdir().unwrap();
514        std::fs::write(a.path().join("service.toml"), "v1").unwrap();
515        std::fs::write(b.path().join("service.toml"), "v2").unwrap();
516        assert_ne!(manifest_sha256(a.path()), manifest_sha256(b.path()));
517    }
518
519    #[test]
520    fn manifest_sha256_stable_for_identical_content() {
521        let a = tempfile::tempdir().unwrap();
522        let b = tempfile::tempdir().unwrap();
523        std::fs::write(a.path().join("service.toml"), "same").unwrap();
524        std::fs::write(b.path().join("service.toml"), "same").unwrap();
525        assert_eq!(manifest_sha256(a.path()), manifest_sha256(b.path()));
526    }
527
528    #[test]
529    fn manifest_sha256_returns_zero_hash_on_missing_file() {
530        let dir = tempfile::tempdir().unwrap();
531        assert_eq!(manifest_sha256(dir.path()), "0".repeat(64));
532    }
533
534    #[test]
535    fn backend_env_map_round_trips_aws_creds() {
536        let settings = BackupSettings {
537            password: "p".into(),
538            backend: BackupBackend::S3 {
539                endpoint: "http://h:9000".into(),
540                bucket: "b".into(),
541                access_key_id: "id".into(),
542                secret_access_key: "secret".into(),
543                prefix: None,
544            },
545        };
546        let env = backend_env_map(&settings.backend);
547        assert_eq!(env.get("AWS_ACCESS_KEY_ID"), Some(&"id".to_string()));
548        assert_eq!(
549            env.get("AWS_SECRET_ACCESS_KEY"),
550            Some(&"secret".to_string())
551        );
552    }
553}