1use 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#[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#[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 pub snapshot: String,
66 pub pre_restore_hook: Option<PathBuf>,
67 pub post_restore_hook: Option<PathBuf>,
68}
69
70pub 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 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
139pub 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
187pub 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
222fn 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 if let Some(b) = backup
255 && !b.paths.is_empty()
256 {
257 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
271fn 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
305fn 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
332pub 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
362pub fn run_hook(
371 kind: &str,
372 service: &str,
373 script: &std::path::Path,
374 service_home: &std::path::Path,
375) -> anyhow::Result<()> {
376 use anyhow::Context;
377 if !script.exists() {
378 return Err(crate::error::Error::BackupHookFailed {
379 service: service.to_string(),
380 hook: kind.to_string(),
381 message: format!("hook script not found: {}", script.display()),
382 }
383 .into());
384 }
385 let env_file = service_home.join(".env");
386 let envs = if env_file.exists() {
387 parse_env_file(&env_file)
388 } else {
389 Vec::new()
390 };
391 let mut cmd = std::process::Command::new("/bin/bash");
392 cmd.arg(script)
393 .env("SERVICE_HOME", service_home)
394 .current_dir(service_home);
395 for (k, v) in envs {
396 cmd.env(k, v);
397 }
398 let status = cmd
399 .status()
400 .with_context(|| format!("running hook {kind} for {service}"))?;
401 if !status.success() {
402 return Err(crate::error::Error::BackupHookFailed {
403 service: service.to_string(),
404 hook: kind.to_string(),
405 message: format!("hook script exited with {}", status.code().unwrap_or(-1)),
406 }
407 .into());
408 }
409 Ok(())
410}
411
412pub fn restic_backup(plan: &BackupRunPlan) -> anyhow::Result<()> {
416 use anyhow::{Context, bail};
417 let mut cmd = std::process::Command::new("restic");
418 cmd.arg("backup")
419 .arg("--repo")
420 .arg(&plan.repo)
421 .env("RESTIC_PASSWORD", &plan.password);
422 for (k, v) in &plan.env {
423 cmd.env(k, v);
424 }
425 for tag in &plan.tags {
426 cmd.arg("--tag").arg(tag);
427 }
428 for excl in &plan.excludes {
429 cmd.arg("--exclude").arg(excl);
432 }
433 cmd.current_dir(&plan.service_home);
434 for path in &plan.paths {
435 cmd.arg(path);
436 }
437 let status = cmd
438 .status()
439 .with_context(|| format!("spawning `restic backup` for {}", plan.service_name))?;
440 if !status.success() {
441 bail!("restic backup exited with {}", status.code().unwrap_or(-1));
442 }
443 Ok(())
444}
445
446pub fn restic_restore(plan: &BackupRestorePlan) -> anyhow::Result<()> {
451 use anyhow::{Context, bail};
452 let mut cmd = std::process::Command::new("restic");
453 cmd.arg("restore")
454 .arg(&plan.snapshot)
455 .arg("--repo")
456 .arg(&plan.repo)
457 .arg("--target")
458 .arg("/")
459 .arg("--tag")
460 .arg(format!("service:{}", plan.service_name))
461 .env("RESTIC_PASSWORD", &plan.password);
462 for (k, v) in &plan.env {
463 cmd.env(k, v);
464 }
465 let status = cmd.status().context("spawning `restic restore`")?;
466 if !status.success() {
467 bail!("restic restore exited with {}", status.code().unwrap_or(-1));
468 }
469 Ok(())
470}
471
472pub fn parse_env_file(path: &std::path::Path) -> Vec<(String, String)> {
475 let Ok(content) = std::fs::read_to_string(path) else {
476 return Vec::new();
477 };
478 content
479 .lines()
480 .filter_map(|l| {
481 let l = l.trim();
482 if l.is_empty() || l.starts_with('#') {
483 return None;
484 }
485 l.split_once('=')
486 .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
487 })
488 .collect()
489}
490
491pub fn execute_backup_run(plan: &BackupRunPlan) -> anyhow::Result<()> {
495 if let Some(hook) = &plan.pre_backup_hook {
496 run_hook("pre_backup", &plan.service_name, hook, &plan.service_home)?;
497 }
498 let restic_result = restic_backup(plan);
499 if let Some(hook) = &plan.post_backup_hook
500 && let Err(e) = run_hook("post_backup", &plan.service_name, hook, &plan.service_home)
501 && restic_result.is_ok()
502 {
503 return Err(e);
504 }
505 restic_result
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use crate::config::schema::{BackupBackend, BackupSettings};
512 use crate::registry::service_def::{
513 Arch, BackupConfig, HttpsRequirement, IntegrationFlags, PortDef, ServiceDef, ServiceMeta,
514 };
515
516 fn def_with_backup(backup_section: Option<BackupConfig>) -> ServiceDef {
517 ServiceDef {
518 service: ServiceMeta {
519 name: "demo".into(),
520 description: "demo".into(),
521 url: None,
522 kind: Default::default(),
523 architecture: vec![Arch::Amd64, Arch::Arm64],
524 https: HttpsRequirement::default(),
525 runtime: Default::default(),
526 run: None,
527 build: None,
528 post_install: None,
529 deploy: Default::default(),
530 health_check: None,
531 health_timeout: None,
532 },
533 requirements: None,
534 ports: vec![PortDef {
535 name: "http".into(),
536 container_port: 80,
537 host_port: None,
538 protocol: Default::default(),
539 tailscale_https: None,
540 }],
541 env: vec![],
542 env_groups: vec![],
543 choices: vec![],
544 requires: vec![],
545 mappings: Default::default(),
546 integrations: IntegrationFlags {
547 backup: backup_section.is_some(),
548 ..Default::default()
549 },
550 capabilities: Default::default(),
551 backup: backup_section,
552 metrics: None,
553 }
554 }
555
556 #[test]
557 fn resolve_paths_whole_folder_when_paths_empty() {
558 let dir = tempfile::tempdir().unwrap();
559 let home = dir.path();
560 let def = def_with_backup(Some(BackupConfig::default()));
562 let (paths, excludes) = resolve_paths(&def.clone(), home).unwrap();
563 assert_eq!(paths, vec![home.to_path_buf()]);
564 assert!(excludes.is_empty());
565 }
566
567 #[test]
568 fn resolve_paths_explicit_list_plus_config_artifacts() {
569 let dir = tempfile::tempdir().unwrap();
570 let home = dir.path();
571 std::fs::write(home.join(".env"), "x").unwrap();
573 std::fs::write(home.join("metadata.toml"), "x").unwrap();
574 let def = def_with_backup(Some(BackupConfig {
575 paths: vec!["data/uploads".into(), ".backup/db.sql".into()],
576 exclude: vec!["data/uploads/cache".into()],
577 ..Default::default()
578 }));
579 let (paths, excludes) = resolve_paths(&def, home).unwrap();
580 assert!(paths.contains(&home.join("data/uploads")), "got {paths:?}");
582 assert!(
583 paths.contains(&home.join(".backup/db.sql")),
584 "got {paths:?}"
585 );
586 assert!(paths.contains(&home.join(".env")), "got {paths:?}");
588 assert!(paths.contains(&home.join("metadata.toml")), "got {paths:?}");
589 assert_eq!(excludes, vec!["data/uploads/cache"]);
590 }
591
592 #[test]
593 fn config_artifacts_collects_env_metadata_quadlets_configs() {
594 let dir = tempfile::tempdir().unwrap();
595 let home = dir.path();
596 std::fs::write(home.join(".env"), "x").unwrap();
597 std::fs::write(home.join("metadata.toml"), "x").unwrap();
598 std::fs::write(home.join("service.manifest"), "x").unwrap();
599 std::fs::write(home.join("demo.container"), "x").unwrap();
600 std::fs::write(home.join("demo.network"), "x").unwrap();
601 std::fs::create_dir(home.join("configs")).unwrap();
602 let names: Vec<String> = config_artifacts(home)
603 .iter()
604 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
605 .collect();
606 for want in [
607 ".env",
608 "metadata.toml",
609 "service.manifest",
610 "demo.container",
611 "demo.network",
612 "configs",
613 ] {
614 assert!(
615 names.contains(&want.to_string()),
616 "{want} missing: {names:?}"
617 );
618 }
619 }
620
621 #[test]
622 fn hook_path_resolves_under_configs_scripts() {
623 let home = PathBuf::from("/x/y");
624 assert_eq!(
625 hook_path(&home, "backup-pre.sh"),
626 PathBuf::from("/x/y/configs/scripts/backup-pre.sh")
627 );
628 }
629
630 #[test]
631 fn resolve_hook_prefers_explicit_over_convention() {
632 let dir = tempfile::tempdir().unwrap();
633 let home = dir.path();
634 let scripts = home.join("configs").join("scripts");
637 std::fs::create_dir_all(&scripts).unwrap();
638 std::fs::write(scripts.join("backup-pre.sh"), "#!/bin/sh\n").unwrap();
639 std::fs::write(scripts.join("custom.sh"), "#!/bin/sh\n").unwrap();
640 let resolved = resolve_hook(Some("custom.sh"), home, "backup-pre.sh");
641 assert_eq!(resolved.unwrap().file_name().unwrap(), "custom.sh");
642 }
643
644 #[test]
645 fn resolve_hook_falls_back_to_convention_when_present() {
646 let dir = tempfile::tempdir().unwrap();
647 let home = dir.path();
648 let scripts = home.join("configs").join("scripts");
649 std::fs::create_dir_all(&scripts).unwrap();
650 std::fs::write(scripts.join("backup-pre.sh"), "#!/bin/sh\n").unwrap();
651 let resolved = resolve_hook(None, home, "backup-pre.sh");
652 assert_eq!(resolved.unwrap().file_name().unwrap(), "backup-pre.sh");
653 }
654
655 #[test]
656 fn resolve_hook_returns_none_when_no_script_exists() {
657 let dir = tempfile::tempdir().unwrap();
658 assert!(resolve_hook(None, dir.path(), "backup-pre.sh").is_none());
660 }
661
662 #[test]
663 fn manifest_sha256_changes_with_content() {
664 let a = tempfile::tempdir().unwrap();
665 let b = tempfile::tempdir().unwrap();
666 std::fs::write(a.path().join("service.toml"), "v1").unwrap();
667 std::fs::write(b.path().join("service.toml"), "v2").unwrap();
668 assert_ne!(manifest_sha256(a.path()), manifest_sha256(b.path()));
669 }
670
671 #[test]
672 fn manifest_sha256_stable_for_identical_content() {
673 let a = tempfile::tempdir().unwrap();
674 let b = tempfile::tempdir().unwrap();
675 std::fs::write(a.path().join("service.toml"), "same").unwrap();
676 std::fs::write(b.path().join("service.toml"), "same").unwrap();
677 assert_eq!(manifest_sha256(a.path()), manifest_sha256(b.path()));
678 }
679
680 #[test]
681 fn manifest_sha256_returns_zero_hash_on_missing_file() {
682 let dir = tempfile::tempdir().unwrap();
683 assert_eq!(manifest_sha256(dir.path()), "0".repeat(64));
684 }
685
686 #[test]
687 fn backend_env_map_round_trips_aws_creds() {
688 let settings = BackupSettings {
689 password: "p".into(),
690 backend: BackupBackend::S3 {
691 endpoint: "http://h:9000".into(),
692 bucket: "b".into(),
693 access_key_id: "id".into(),
694 secret_access_key: "secret".into(),
695 prefix: None,
696 },
697 };
698 let env = backend_env_map(&settings.backend);
699 assert_eq!(env.get("AWS_ACCESS_KEY_ID"), Some(&"id".to_string()));
700 assert_eq!(
701 env.get("AWS_SECRET_ACCESS_KEY"),
702 Some(&"secret".to_string())
703 );
704 }
705}