1use 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#[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 (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
130pub 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
178pub 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
213fn 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 let (data, _ephemeral) = classify_home_dir(home)?;
237 let mut paths: Vec<PathBuf> = data;
238
239 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
262fn 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
289pub 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 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 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 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 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}