WP-30/31/32: contract and failing tests for backup management; OS scan skips container dirs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@ -15,9 +15,11 @@ tokio.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
sha2.workspace = true
|
||||
hex = "0.4"
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio.workspace = true
|
||||
|
||||
[lints]
|
||||
|
||||
115
backend/crates/application/src/backup_service.rs
Normal file
115
backend/crates/application/src/backup_service.rs
Normal 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!()
|
||||
}
|
||||
}
|
||||
@ -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};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
374
backend/crates/application/src/tests/backup_tests.rs
Normal file
374
backend/crates/application/src/tests/backup_tests.rs
Normal 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"));
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
mod auth_service_tests;
|
||||
mod backup_tests;
|
||||
mod cluster_tests;
|
||||
mod inventory_tests;
|
||||
mod jobs_tests;
|
||||
|
||||
Reference in New Issue
Block a user