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
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 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 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 assert!(paths.contains(&home.join("data/uploads")), "got {paths:?}");
430 assert!(
431 paths.contains(&home.join(".backup/db.sql")),
432 "got {paths:?}"
433 );
434 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 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 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}