WP-30/31/32: contract and failing tests for backup management; OS scan skips container dirs
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 23:05:22 +02:00
parent 5026ce22de
commit 39af18b336
16 changed files with 1456 additions and 1 deletions

View File

@ -0,0 +1,115 @@
//! Backup targets, strategies and execution.
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::backup::{BackupRecord, BackupStrategy, BackupTarget};
use domain::ports::{
BackupCollector, BackupRecordRepository, BackupStorage, BackupStrategyRepository,
BackupTargetRepository, Cipher, FileEncryptor,
};
use domain::DomainError;
use uuid::Uuid;
use crate::jobs::{JobHandler, JobLog};
pub struct BackupDeps {
pub targets: Arc<dyn BackupTargetRepository>,
pub strategies: Arc<dyn BackupStrategyRepository>,
pub records: Arc<dyn BackupRecordRepository>,
pub storage: Arc<dyn BackupStorage>,
pub collector: Arc<dyn BackupCollector>,
pub encryptor: Arc<dyn FileEncryptor>,
pub cipher: Arc<dyn Cipher>,
pub work_dir: PathBuf,
}
pub struct BackupService {
d: BackupDeps,
}
/// Strategy with its most recent record, for overviews.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct StrategyStatus {
pub strategy: BackupStrategy,
pub target_name: String,
pub last_backup: Option<BackupRecord>,
}
impl BackupService {
pub fn new(deps: BackupDeps) -> Self {
Self { d: deps }
}
// ---- targets ----
pub async fn list_targets(&self) -> Result<Vec<BackupTarget>, DomainError> {
todo!()
}
pub async fn get_target(&self, _id: Uuid) -> Result<BackupTarget, DomainError> {
todo!()
}
pub async fn create_target(&self, _t: BackupTarget) -> Result<BackupTarget, DomainError> {
todo!()
}
/// Empty password keeps the stored one.
pub async fn update_target(&self, _t: BackupTarget) -> Result<BackupTarget, DomainError> {
todo!()
}
/// Fails with `Conflict` while a strategy still uses the target.
pub async fn delete_target(&self, _id: Uuid) -> Result<(), DomainError> {
todo!()
}
pub async fn test_target(&self, _id: Uuid) -> Result<(), DomainError> {
todo!()
}
// ---- strategies ----
pub async fn list_strategies(&self) -> Result<Vec<StrategyStatus>, DomainError> {
todo!()
}
pub async fn get_strategy(&self, _id: Uuid) -> Result<BackupStrategy, DomainError> {
todo!()
}
pub async fn create_strategy(&self, _s: BackupStrategy) -> Result<BackupStrategy, DomainError> {
todo!()
}
/// Empty passphrase keeps the stored one; `None` removes it.
pub async fn update_strategy(&self, _s: BackupStrategy) -> Result<BackupStrategy, DomainError> {
todo!()
}
pub async fn delete_strategy(&self, _id: Uuid) -> Result<(), DomainError> {
todo!()
}
pub async fn records(&self, _strategy_id: Uuid) -> Result<Vec<BackupRecord>, DomainError> {
todo!()
}
/// Enabled strategies whose cron fired since their last backup.
pub async fn due_strategies(
&self,
_now: DateTime<Utc>,
_grace_secs: i64,
) -> Result<Vec<Uuid>, DomainError> {
todo!()
}
/// Collect, encrypt, upload, verify, record, apply retention.
pub async fn run_strategy(
&self,
_id: Uuid,
_log: &dyn JobLog,
) -> Result<BackupRecord, DomainError> {
todo!()
}
}
/// Job handler for `JobKind::Backup`; params = strategy id.
pub struct BackupJob(pub Arc<BackupService>);
#[async_trait]
impl JobHandler for BackupJob {
async fn run(&self, _params: Option<String>, _log: &dyn JobLog) -> Result<(), String> {
todo!()
}
}

View File

@ -1,5 +1,6 @@
//! Application layer: use cases orchestrating the domain through its ports.
pub mod auth_service;
pub mod backup_service;
pub mod cluster_service;
pub mod inventory_service;
pub mod jobs;
@ -10,6 +11,7 @@ pub mod user_service;
pub mod vuln_service;
pub use auth_service::AuthService;
pub use backup_service::{BackupDeps, BackupJob, BackupService};
pub use cluster_service::ClusterService;
pub use inventory_service::{InventoryService, PackageRefreshJob};
pub use jobs::{JobHandler, JobLog, JobRunner};

View File

@ -651,3 +651,246 @@ impl FindingRepository for MemFindings {
Ok(c)
}
}
use domain::backup::{
BackupRecord, BackupSource, BackupStrategy, BackupTarget, RemoteFile, StorageKind,
};
use domain::ports::{
BackupCollector, BackupRecordRepository, BackupStorage, BackupStrategyRepository,
BackupTargetRepository, FileEncryptor, LineSink as _LineSink,
};
use std::path::{Path, PathBuf};
#[derive(Default)]
pub struct MemTargets(pub Mutex<Vec<BackupTarget>>);
#[async_trait]
impl BackupTargetRepository for MemTargets {
async fn list(&self) -> Result<Vec<BackupTarget>, DomainError> {
Ok(self.0.lock().unwrap().clone())
}
async fn get(&self, id: Uuid) -> Result<Option<BackupTarget>, DomainError> {
Ok(self.0.lock().unwrap().iter().find(|t| t.id == id).cloned())
}
async fn insert(&self, t: &BackupTarget) -> Result<(), DomainError> {
self.0.lock().unwrap().push(t.clone());
Ok(())
}
async fn update(&self, t: &BackupTarget) -> Result<(), DomainError> {
let mut v = self.0.lock().unwrap();
let x = v
.iter_mut()
.find(|x| x.id == t.id)
.ok_or(DomainError::NotFound)?;
*x = t.clone();
Ok(())
}
async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
let mut v = self.0.lock().unwrap();
let before = v.len();
v.retain(|t| t.id != id);
(v.len() < before)
.then_some(())
.ok_or(DomainError::NotFound)
}
}
#[derive(Default)]
pub struct MemStrategies(pub Mutex<Vec<BackupStrategy>>);
#[async_trait]
impl BackupStrategyRepository for MemStrategies {
async fn list(&self) -> Result<Vec<BackupStrategy>, DomainError> {
Ok(self.0.lock().unwrap().clone())
}
async fn get(&self, id: Uuid) -> Result<Option<BackupStrategy>, DomainError> {
Ok(self.0.lock().unwrap().iter().find(|t| t.id == id).cloned())
}
async fn insert(&self, s: &BackupStrategy) -> Result<(), DomainError> {
self.0.lock().unwrap().push(s.clone());
Ok(())
}
async fn update(&self, s: &BackupStrategy) -> Result<(), DomainError> {
let mut v = self.0.lock().unwrap();
let x = v
.iter_mut()
.find(|x| x.id == s.id)
.ok_or(DomainError::NotFound)?;
*x = s.clone();
Ok(())
}
async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
let mut v = self.0.lock().unwrap();
let before = v.len();
v.retain(|t| t.id != id);
(v.len() < before)
.then_some(())
.ok_or(DomainError::NotFound)
}
}
#[derive(Default)]
pub struct MemRecords(pub Mutex<Vec<BackupRecord>>);
#[async_trait]
impl BackupRecordRepository for MemRecords {
async fn insert(&self, r: &BackupRecord) -> Result<(), DomainError> {
self.0.lock().unwrap().push(r.clone());
Ok(())
}
async fn list_for(
&self,
strategy_id: Uuid,
limit: u32,
) -> Result<Vec<BackupRecord>, DomainError> {
let mut v: Vec<_> = self
.0
.lock()
.unwrap()
.iter()
.filter(|r| r.strategy_id == strategy_id)
.cloned()
.collect();
v.sort_by(|a, b| b.created_at.cmp(&a.created_at));
v.truncate(limit as usize);
Ok(v)
}
async fn delete_by_filename(
&self,
strategy_id: Uuid,
filename: &str,
) -> Result<(), DomainError> {
self.0
.lock()
.unwrap()
.retain(|r| !(r.strategy_id == strategy_id && r.filename == filename));
Ok(())
}
}
/// In-memory remote storage keyed by target id; `fail` makes every call fail.
#[derive(Default)]
pub struct MemStorage {
pub files: Mutex<HashMap<Uuid, Vec<RemoteFile>>>,
pub fail: bool,
pub ops: Mutex<Vec<String>>,
}
#[async_trait]
impl BackupStorage for MemStorage {
async fn test(&self, t: &BackupTarget) -> Result<(), DomainError> {
self.ops.lock().unwrap().push(format!("test {}", t.name));
if self.fail {
Err(DomainError::Unavailable("connection refused".into()))
} else {
Ok(())
}
}
async fn upload(
&self,
t: &BackupTarget,
local: &Path,
remote_name: &str,
) -> Result<(), DomainError> {
if self.fail {
return Err(DomainError::Unavailable("upload failed".into()));
}
let size = std::fs::metadata(local).map(|m| m.len()).unwrap_or(0);
self.ops
.lock()
.unwrap()
.push(format!("upload {remote_name}"));
self.files
.lock()
.unwrap()
.entry(t.id)
.or_default()
.push(RemoteFile {
name: remote_name.into(),
size_bytes: size,
});
Ok(())
}
async fn list(&self, t: &BackupTarget) -> Result<Vec<RemoteFile>, DomainError> {
Ok(self
.files
.lock()
.unwrap()
.get(&t.id)
.cloned()
.unwrap_or_default())
}
async fn delete(&self, t: &BackupTarget, remote_name: &str) -> Result<(), DomainError> {
self.ops
.lock()
.unwrap()
.push(format!("delete {remote_name}"));
self.files
.lock()
.unwrap()
.entry(t.id)
.or_default()
.retain(|f| f.name != remote_name);
Ok(())
}
}
/// Writes a small file describing the source.
pub struct FakeCollector;
#[async_trait]
impl BackupCollector for FakeCollector {
async fn collect(
&self,
source: &BackupSource,
work_dir: &Path,
out: &dyn _LineSink,
) -> Result<PathBuf, DomainError> {
out.line(&format!("collecting {source:?}"));
let p = work_dir.join(format!("archive.{}", source.extension()));
std::fs::write(&p, format!("fake archive of {source:?}"))
.map_err(|e| DomainError::Storage(e.to_string()))?;
Ok(p)
}
}
pub struct FakeEncryptor;
#[async_trait]
impl FileEncryptor for FakeEncryptor {
async fn encrypt(&self, input: &Path, passphrase: &str) -> Result<PathBuf, DomainError> {
let out = input.with_extension(format!(
"{}.enc",
input.extension().and_then(|e| e.to_str()).unwrap_or("")
));
let data = std::fs::read(input).map_err(|e| DomainError::Storage(e.to_string()))?;
std::fs::write(&out, [b"ENC:", passphrase.as_bytes(), b":", &data].concat())
.map_err(|e| DomainError::Storage(e.to_string()))?;
Ok(out)
}
}
pub fn target(name: &str) -> BackupTarget {
BackupTarget {
id: Uuid::new_v4(),
name: name.into(),
kind: StorageKind::Smb,
host: "nas.local".into(),
port: None,
share: "backups".into(),
path: "softvisor".into(),
username: "backup".into(),
password: "smb-secret".into(),
tls: false,
}
}
pub fn strategy(name: &str, target_id: Uuid) -> BackupStrategy {
BackupStrategy {
id: Uuid::new_v4(),
name: name.into(),
source: BackupSource::PostgresDump {
namespace: "gitea".into(),
pod: "gitea-postgresql-0".into(),
},
schedule: "0 0 2 * * *".into(),
target_id,
retention: 2,
passphrase: None,
enabled: true,
}
}

View File

@ -0,0 +1,374 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use chrono::{Duration, TimeZone, Utc};
use domain::backup::{BackupSource, StorageKind};
use domain::DomainError;
use crate::jobs::{JobHandler, JobLog};
use crate::test_fakes::{
strategy, target, FakeCipher, FakeCollector, FakeEncryptor, MemRecords, MemStorage,
MemStrategies, MemTargets,
};
use crate::{BackupDeps, BackupJob, BackupService};
#[derive(Default)]
struct VecLog(Mutex<Vec<String>>);
#[async_trait]
impl JobLog for VecLog {
async fn line(&self, text: &str) {
self.0.lock().unwrap().push(text.into());
}
}
struct F {
targets: Arc<MemTargets>,
strategies: Arc<MemStrategies>,
records: Arc<MemRecords>,
storage: Arc<MemStorage>,
work: tempfile::TempDir,
}
fn fixture(fail_storage: bool) -> (F, Arc<BackupService>) {
let targets = Arc::new(MemTargets::default());
let strategies = Arc::new(MemStrategies::default());
let records = Arc::new(MemRecords::default());
let storage = Arc::new(MemStorage {
fail: fail_storage,
..Default::default()
});
let work = tempfile::tempdir().unwrap();
let svc = BackupService::new(BackupDeps {
targets: targets.clone(),
strategies: strategies.clone(),
records: records.clone(),
storage: storage.clone(),
collector: Arc::new(FakeCollector),
encryptor: Arc::new(FakeEncryptor),
cipher: Arc::new(FakeCipher),
work_dir: work.path().to_path_buf(),
});
(
F {
targets,
strategies,
records,
storage,
work,
},
Arc::new(svc),
)
}
#[tokio::test]
async fn targets_crud_with_encrypted_password_and_masked_update() {
let (f, svc) = fixture(false);
let t = svc.create_target(target("NAS")).await.unwrap();
assert_eq!(t.password, "smb-secret", "returned in plain text");
let stored = f.targets.0.lock().unwrap()[0].clone();
assert!(stored.password.starts_with("enc:"), "stored encrypted");
assert_eq!(svc.get_target(t.id).await.unwrap().password, "smb-secret");
// update with empty password keeps the old one
let upd = svc
.update_target(domain::backup::BackupTarget {
password: String::new(),
host: "nas2.local".into(),
..t.clone()
})
.await
.unwrap();
assert_eq!(upd.host, "nas2.local");
assert_eq!(upd.password, "smb-secret");
let upd = svc
.update_target(domain::backup::BackupTarget {
password: "new-secret".into(),
..t.clone()
})
.await
.unwrap();
assert_eq!(upd.password, "new-secret");
let mut bad = target("x");
bad.share = String::new();
assert!(matches!(
svc.create_target(bad).await.unwrap_err(),
DomainError::Validation(_)
));
let mut ftp = target("ftp");
ftp.kind = StorageKind::Ftp;
ftp.share = String::new();
svc.create_target(ftp).await.unwrap();
assert_eq!(svc.list_targets().await.unwrap().len(), 2);
svc.test_target(t.id).await.unwrap();
assert_eq!(f.storage.ops.lock().unwrap()[0], "test NAS");
svc.delete_target(t.id).await.unwrap();
assert_eq!(
svc.get_target(t.id).await.unwrap_err(),
DomainError::NotFound
);
}
#[tokio::test]
async fn strategies_crud_validation_and_target_protection() {
let (f, svc) = fixture(false);
let t = svc.create_target(target("NAS")).await.unwrap();
let mut s = strategy("Gitea DB", t.id);
s.passphrase = Some("a-long-passphrase!".into());
let created = svc.create_strategy(s.clone()).await.unwrap();
assert!(f.strategies.0.lock().unwrap()[0]
.passphrase
.as_ref()
.unwrap()
.starts_with("enc:"));
assert_eq!(
svc.get_strategy(created.id)
.await
.unwrap()
.passphrase
.as_deref(),
Some("a-long-passphrase!")
);
// validation: cron, retention, unknown target, source names
let bad = domain::backup::BackupStrategy {
schedule: "nope".into(),
..s.clone()
};
assert!(matches!(
svc.create_strategy(bad).await.unwrap_err(),
DomainError::Validation(_)
));
let bad = domain::backup::BackupStrategy {
retention: 0,
..s.clone()
};
assert!(matches!(
svc.create_strategy(bad).await.unwrap_err(),
DomainError::Validation(_)
));
let bad = domain::backup::BackupStrategy {
target_id: uuid::Uuid::new_v4(),
..s.clone()
};
assert_eq!(
svc.create_strategy(bad).await.unwrap_err(),
DomainError::NotFound
);
let bad = domain::backup::BackupStrategy {
source: BackupSource::HostPath {
path: "relative/../x".into(),
},
..s.clone()
};
assert!(matches!(
svc.create_strategy(bad).await.unwrap_err(),
DomainError::Validation(_)
));
// target in use cannot be deleted
assert!(matches!(
svc.delete_target(t.id).await.unwrap_err(),
DomainError::Conflict(_)
));
// update: empty passphrase keeps, None removes
let upd = svc
.update_strategy(domain::backup::BackupStrategy {
passphrase: Some(String::new()),
retention: 5,
..created.clone()
})
.await
.unwrap();
assert_eq!(upd.passphrase.as_deref(), Some("a-long-passphrase!"));
assert_eq!(upd.retention, 5);
let upd = svc
.update_strategy(domain::backup::BackupStrategy {
passphrase: None,
..created.clone()
})
.await
.unwrap();
assert_eq!(upd.passphrase, None);
let list = svc.list_strategies().await.unwrap();
assert_eq!(list[0].target_name, "NAS");
assert_eq!(list[0].last_backup, None);
svc.delete_strategy(created.id).await.unwrap();
svc.delete_target(t.id).await.unwrap();
}
#[tokio::test]
async fn run_uploads_archive_records_it_and_applies_retention() {
let (f, svc) = fixture(false);
let t = svc.create_target(target("NAS")).await.unwrap();
let s = svc
.create_strategy(strategy("Gitea DB", t.id))
.await
.unwrap();
let log = VecLog::default();
let r1 = svc.run_strategy(s.id, &log).await.unwrap();
assert!(
r1.filename.starts_with("gitea-db_") && r1.filename.ends_with(".sql.gz"),
"{}",
r1.filename
);
assert!(r1.size_bytes > 0);
assert_eq!(r1.sha256.len(), 64);
let lines = log.0.lock().unwrap().join("\n");
assert!(lines.contains("collecting"), "{lines}");
assert!(lines.contains("uploaded"), "{lines}");
assert!(
f.work.path().read_dir().unwrap().next().is_none(),
"work dir cleaned up"
);
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
svc.run_strategy(s.id, &VecLog::default()).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let r3 = svc.run_strategy(s.id, &VecLog::default()).await.unwrap();
// retention 2: oldest removed remotely and from records
let remote = f.storage.files.lock().unwrap().get(&t.id).cloned().unwrap();
assert_eq!(remote.len(), 2, "{remote:?}");
assert!(remote.iter().all(|x| x.name != r1.filename));
assert!(f
.storage
.ops
.lock()
.unwrap()
.contains(&format!("delete {}", r1.filename)));
let recs = svc.records(s.id).await.unwrap();
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].filename, r3.filename, "newest first");
assert_eq!(
svc.list_strategies().await.unwrap()[0]
.last_backup
.as_ref()
.unwrap()
.filename,
r3.filename
);
}
#[tokio::test]
async fn encrypted_strategy_uploads_enc_file() {
let (f, svc) = fixture(false);
let t = svc.create_target(target("NAS")).await.unwrap();
let mut s = strategy("Repos", t.id);
s.source = BackupSource::VolumeClaim {
namespace: "gitea".into(),
pvc: "gitea-shared-storage".into(),
};
s.passphrase = Some("correct horse battery".into());
let s = svc.create_strategy(s).await.unwrap();
let r = svc.run_strategy(s.id, &VecLog::default()).await.unwrap();
assert!(r.filename.ends_with(".tar.gz.enc"), "{}", r.filename);
let remote = f.storage.files.lock().unwrap().get(&t.id).cloned().unwrap();
assert_eq!(remote[0].name, r.filename);
assert_eq!(remote[0].size_bytes, r.size_bytes);
}
#[tokio::test]
async fn upload_failure_fails_run_and_cleans_up() {
let (f, svc) = fixture(true);
let t = svc.create_target(target("NAS")).await.unwrap();
let s = svc.create_strategy(strategy("x", t.id)).await.unwrap();
let log = VecLog::default();
assert!(matches!(
svc.run_strategy(s.id, &log).await.unwrap_err(),
DomainError::Unavailable(_)
));
assert!(f.records.0.lock().unwrap().is_empty());
assert!(
f.work.path().read_dir().unwrap().next().is_none(),
"work dir cleaned up"
);
assert!(matches!(
svc.test_target(t.id).await.unwrap_err(),
DomainError::Unavailable(_)
));
}
#[tokio::test]
async fn due_strategies_follow_cron_and_last_backup() {
let (f, svc) = fixture(false);
let t = svc.create_target(target("NAS")).await.unwrap();
let daily = svc.create_strategy(strategy("daily", t.id)).await.unwrap(); // 02:00
let mut off = strategy("off", t.id);
off.enabled = false;
let off = svc.create_strategy(off).await.unwrap();
let now = Utc.with_ymd_and_hms(2026, 9, 3, 2, 0, 30).unwrap();
// never ran: due within grace after 02:00
assert_eq!(svc.due_strategies(now, 120).await.unwrap(), vec![daily.id]);
assert!(svc
.due_strategies(now + Duration::hours(1), 120)
.await
.unwrap()
.is_empty());
// ran yesterday -> due again at 02:00 today, even if the tick is late
f.records
.0
.lock()
.unwrap()
.push(domain::backup::BackupRecord {
id: uuid::Uuid::new_v4(),
strategy_id: daily.id,
filename: "daily_x".into(),
size_bytes: 1,
sha256: String::new(),
created_at: now - Duration::days(1),
});
assert_eq!(
svc.due_strategies(now + Duration::hours(1), 120)
.await
.unwrap(),
vec![daily.id]
);
// ran just now -> not due
f.records
.0
.lock()
.unwrap()
.push(domain::backup::BackupRecord {
id: uuid::Uuid::new_v4(),
strategy_id: daily.id,
filename: "daily_y".into(),
size_bytes: 1,
sha256: String::new(),
created_at: now,
});
assert!(svc
.due_strategies(now + Duration::hours(1), 120)
.await
.unwrap()
.is_empty());
let _ = off;
}
#[tokio::test]
async fn backup_job_runs_strategy_from_params() {
let (_, svc) = fixture(false);
let t = svc.create_target(target("NAS")).await.unwrap();
let s = svc.create_strategy(strategy("job", t.id)).await.unwrap();
let job = BackupJob(svc.clone());
let log = VecLog::default();
job.run(Some(s.id.to_string()), &log).await.unwrap();
assert!(log.0.lock().unwrap().iter().any(|l| l.contains("job_")));
assert!(job
.run(None, &VecLog::default())
.await
.unwrap_err()
.contains("strategy"));
assert!(job
.run(Some("not-a-uuid".into()), &VecLog::default())
.await
.is_err());
assert!(job
.run(Some(uuid::Uuid::new_v4().to_string()), &VecLog::default())
.await
.unwrap_err()
.contains("not found"));
}

View File

@ -1,4 +1,5 @@
mod auth_service_tests;
mod backup_tests;
mod cluster_tests;
mod inventory_tests;
mod jobs_tests;