use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::config::ConfigPaths;
use crate::config::schema::{BackupBackend, Config};
use crate::error::{Error, Result};
use crate::metadata::{Metadata, load_metadata};
use crate::paths::service_home;
use crate::registry;
use crate::registry::service_def::ServiceDef;
const SERVICE_TOML_FILENAME: &str = "service.toml";
#[derive(Debug, Clone)]
pub struct BackupRunPlan {
pub service_name: String,
pub service_home: PathBuf,
pub repo: String,
pub password: String,
pub env: BTreeMap<String, String>,
pub tags: Vec<String>,
pub paths: Vec<PathBuf>,
pub excludes: Vec<String>,
pub pre_backup_hook: Option<PathBuf>,
pub post_backup_hook: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct BackupRestorePlan {
pub service_name: String,
pub service_home: PathBuf,
pub repo: String,
pub password: String,
pub env: BTreeMap<String, String>,
pub snapshot: String,
pub pre_restore_hook: Option<PathBuf>,
pub post_restore_hook: Option<PathBuf>,
}
pub fn plan_backup_run(
service_name: &str,
config: &Config,
repo_dir: &Path,
) -> Result<BackupRunPlan> {
let metadata = load_install_metadata(service_name)?;
if !metadata.backup_enabled {
return Err(Error::BackupNotEnabled(service_name.to_string()));
}
let settings = config
.backup
.as_ref()
.ok_or(Error::BackupRepoNotConfigured)?;
let svc = registry::find_service(repo_dir, service_name)?;
if !svc.def.integrations.backup {
return Err(Error::BackupNotSupported(service_name.to_string()));
}
let home = service_home(service_name)?;
let (mut paths, excludes) = resolve_paths(&svc.def, &home)?;
let prefs = ConfigPaths::resolve()?.config_file;
if prefs.exists() {
paths.push(prefs);
}
let manifest_sha = manifest_sha256(&svc.service_dir);
let mut tags = vec![format!("service:{service_name}")];
tags.push(format!("manifest_sha:{}", &manifest_sha[..16]));
let backup = svc.def.backup.as_ref();
let pre = resolve_hook(
backup.and_then(|b| b.pre_backup.as_deref()),
&home,
"backup-pre.sh",
);
let post = resolve_hook(
backup.and_then(|b| b.post_backup.as_deref()),
&home,
"backup-post.sh",
);
Ok(BackupRunPlan {
service_name: service_name.to_string(),
service_home: home,
repo: settings.backend.restic_repo(),
password: settings.password.clone(),
env: backend_env_map(&settings.backend),
tags,
paths,
excludes,
pre_backup_hook: pre,
post_backup_hook: post,
})
}
pub fn plan_backup_restore(
service_name: &str,
snapshot: &str,
config: &Config,
repo_dir: &Path,
) -> Result<BackupRestorePlan> {
let metadata = load_install_metadata(service_name)?;
if !metadata.backup_enabled {
return Err(Error::BackupNotEnabled(service_name.to_string()));
}
let settings = config
.backup
.as_ref()
.ok_or(Error::BackupRepoNotConfigured)?;
let svc = registry::find_service(repo_dir, service_name)?;
let home = service_home(service_name)?;
let backup = svc.def.backup.as_ref();
let pre = resolve_hook(
backup.and_then(|b| b.pre_restore.as_deref()),
&home,
"restore-pre.sh",
);
let post = resolve_hook(
backup.and_then(|b| b.post_restore.as_deref()),
&home,
"restore-post.sh",
);
Ok(BackupRestorePlan {
service_name: service_name.to_string(),
service_home: home,
repo: settings.backend.restic_repo(),
password: settings.password.clone(),
env: backend_env_map(&settings.backend),
snapshot: snapshot.to_string(),
pre_restore_hook: pre,
post_restore_hook: post,
})
}
pub fn list_backup_enabled() -> Result<Vec<String>> {
let root = crate::paths::service_data_root()?;
if !root.is_dir() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&root).map_err(|source| Error::FileRead {
path: root.clone(),
source,
})? {
let entry = entry.map_err(|source| Error::FileRead {
path: root.clone(),
source,
})?;
let name = match entry.file_name().to_str() {
Some(s) => s.to_string(),
None => continue,
};
if let Some(meta) = load_metadata(&name)?
&& meta.backup_enabled
{
out.push(name);
}
}
out.sort();
Ok(out)
}
fn load_install_metadata(service_name: &str) -> Result<Metadata> {
load_metadata(service_name)?.ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))
}
fn resolve_paths(def: &ServiceDef, home: &Path) -> Result<(Vec<PathBuf>, Vec<String>)> {
let backup = def.backup.as_ref();
let excludes: Vec<String> = backup.map(|b| b.exclude.clone()).unwrap_or_default();
if let Some(b) = backup
&& !b.paths.is_empty()
{
let mut abs: Vec<PathBuf> = b.paths.iter().map(|p| home.join(p)).collect();
abs.extend(config_artifacts(home));
abs.sort();
abs.dedup();
return Ok((abs, excludes));
}
Ok((vec![home.to_path_buf()], excludes))
}
fn config_artifacts(home: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
for f in [".env", "metadata.toml", "service.manifest"] {
let p = home.join(f);
if p.exists() {
out.push(p);
}
}
let configs = home.join("configs");
if configs.is_dir() {
out.push(configs);
}
if let Ok(entries) = std::fs::read_dir(home) {
for entry in entries.flatten() {
let name = entry.file_name();
let n = name.to_string_lossy();
if n.ends_with(".container") || n.ends_with(".network") || n.ends_with(".volume") {
out.push(entry.path());
}
}
}
out
}
fn hook_path(home: &Path, filename: &str) -> PathBuf {
home.join("configs").join("scripts").join(filename)
}
fn resolve_hook(explicit: Option<&str>, home: &Path, conventional: &str) -> Option<PathBuf> {
if let Some(name) = explicit {
return Some(hook_path(home, name));
}
let conv = hook_path(home, conventional);
if conv.exists() { Some(conv) } else { None }
}
fn backend_env_map(backend: &BackupBackend) -> BTreeMap<String, String> {
backend
.env()
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect()
}
pub fn manifest_sha256(service_dir: &Path) -> String {
let path = service_dir.join(SERVICE_TOML_FILENAME);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => return "0".repeat(64),
};
let mut hasher = Sha256::new();
hasher.update(&bytes);
let digest = hasher.finalize();
hex_encode(&digest)
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(HEX[(b >> 4) as usize] as char);
s.push(HEX[(b & 0xf) as usize] as char);
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::{BackupBackend, BackupSettings};
use crate::registry::service_def::{
Arch, BackupConfig, HttpsRequirement, IntegrationFlags, PortDef, ServiceDef, ServiceMeta,
};
fn def_with_backup(backup_section: Option<BackupConfig>) -> ServiceDef {
ServiceDef {
service: ServiceMeta {
name: "demo".into(),
description: "demo".into(),
url: None,
kind: Default::default(),
architecture: vec![Arch::Amd64, Arch::Arm64],
https: HttpsRequirement::default(),
runtime: Default::default(),
run: None,
build: None,
},
requirements: None,
ports: vec![PortDef {
name: "http".into(),
container_port: 80,
host_port: None,
protocol: Default::default(),
tailscale_https: None,
}],
env: vec![],
env_groups: vec![],
requires: vec![],
mappings: Default::default(),
integrations: IntegrationFlags {
backup: backup_section.is_some(),
..Default::default()
},
capabilities: Default::default(),
backup: backup_section,
}
}
#[test]
fn resolve_paths_whole_folder_when_paths_empty() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let def = def_with_backup(Some(BackupConfig::default()));
let (paths, excludes) = resolve_paths(&def.clone(), home).unwrap();
assert_eq!(paths, vec![home.to_path_buf()]);
assert!(excludes.is_empty());
}
#[test]
fn resolve_paths_explicit_list_plus_config_artifacts() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
std::fs::write(home.join(".env"), "x").unwrap();
std::fs::write(home.join("metadata.toml"), "x").unwrap();
let def = def_with_backup(Some(BackupConfig {
paths: vec!["data/uploads".into(), ".backup/db.sql".into()],
exclude: vec!["data/uploads/cache".into()],
..Default::default()
}));
let (paths, excludes) = resolve_paths(&def, home).unwrap();
assert!(paths.contains(&home.join("data/uploads")), "got {paths:?}");
assert!(
paths.contains(&home.join(".backup/db.sql")),
"got {paths:?}"
);
assert!(paths.contains(&home.join(".env")), "got {paths:?}");
assert!(paths.contains(&home.join("metadata.toml")), "got {paths:?}");
assert_eq!(excludes, vec!["data/uploads/cache"]);
}
#[test]
fn config_artifacts_collects_env_metadata_quadlets_configs() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
std::fs::write(home.join(".env"), "x").unwrap();
std::fs::write(home.join("metadata.toml"), "x").unwrap();
std::fs::write(home.join("service.manifest"), "x").unwrap();
std::fs::write(home.join("demo.container"), "x").unwrap();
std::fs::write(home.join("demo.network"), "x").unwrap();
std::fs::create_dir(home.join("configs")).unwrap();
let names: Vec<String> = config_artifacts(home)
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
for want in [
".env",
"metadata.toml",
"service.manifest",
"demo.container",
"demo.network",
"configs",
] {
assert!(
names.contains(&want.to_string()),
"{want} missing: {names:?}"
);
}
}
#[test]
fn hook_path_resolves_under_configs_scripts() {
let home = PathBuf::from("/x/y");
assert_eq!(
hook_path(&home, "backup-pre.sh"),
PathBuf::from("/x/y/configs/scripts/backup-pre.sh")
);
}
#[test]
fn resolve_hook_prefers_explicit_over_convention() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let scripts = home.join("configs").join("scripts");
std::fs::create_dir_all(&scripts).unwrap();
std::fs::write(scripts.join("backup-pre.sh"), "#!/bin/sh\n").unwrap();
std::fs::write(scripts.join("custom.sh"), "#!/bin/sh\n").unwrap();
let resolved = resolve_hook(Some("custom.sh"), home, "backup-pre.sh");
assert_eq!(resolved.unwrap().file_name().unwrap(), "custom.sh");
}
#[test]
fn resolve_hook_falls_back_to_convention_when_present() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let scripts = home.join("configs").join("scripts");
std::fs::create_dir_all(&scripts).unwrap();
std::fs::write(scripts.join("backup-pre.sh"), "#!/bin/sh\n").unwrap();
let resolved = resolve_hook(None, home, "backup-pre.sh");
assert_eq!(resolved.unwrap().file_name().unwrap(), "backup-pre.sh");
}
#[test]
fn resolve_hook_returns_none_when_no_script_exists() {
let dir = tempfile::tempdir().unwrap();
assert!(resolve_hook(None, dir.path(), "backup-pre.sh").is_none());
}
#[test]
fn manifest_sha256_changes_with_content() {
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
std::fs::write(a.path().join("service.toml"), "v1").unwrap();
std::fs::write(b.path().join("service.toml"), "v2").unwrap();
assert_ne!(manifest_sha256(a.path()), manifest_sha256(b.path()));
}
#[test]
fn manifest_sha256_stable_for_identical_content() {
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
std::fs::write(a.path().join("service.toml"), "same").unwrap();
std::fs::write(b.path().join("service.toml"), "same").unwrap();
assert_eq!(manifest_sha256(a.path()), manifest_sha256(b.path()));
}
#[test]
fn manifest_sha256_returns_zero_hash_on_missing_file() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(manifest_sha256(dir.path()), "0".repeat(64));
}
#[test]
fn backend_env_map_round_trips_aws_creds() {
let settings = BackupSettings {
password: "p".into(),
backend: BackupBackend::S3 {
endpoint: "http://h:9000".into(),
bucket: "b".into(),
access_key_id: "id".into(),
secret_access_key: "secret".into(),
prefix: None,
},
};
let env = backend_env_map(&settings.backend);
assert_eq!(env.get("AWS_ACCESS_KEY_ID"), Some(&"id".to_string()));
assert_eq!(
env.get("AWS_SECRET_ACCESS_KEY"),
Some(&"secret".to_string())
);
}
}