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:
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user