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:
34
backend/Cargo.lock
generated
34
backend/Cargo.lock
generated
@ -101,10 +101,12 @@ dependencies = [
|
||||
"chrono",
|
||||
"cron",
|
||||
"domain",
|
||||
"hex",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
@ -1305,6 +1307,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
@ -1813,6 +1821,19 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
@ -2379,6 +2400,19 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
|
||||
168
backend/crates/api/tests/backups.rs
Normal file
168
backend/crates/api/tests/backups.rs
Normal file
@ -0,0 +1,168 @@
|
||||
//! WP-30/31/32: /api/backups
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{get, post, send_json, test_app_with_admin};
|
||||
use serde_json::json;
|
||||
|
||||
const ADMIN: &str = "admin@example.com";
|
||||
const PW: &str = "admin-password-123";
|
||||
|
||||
fn smb() -> serde_json::Value {
|
||||
json!({"name": "NAS", "kind": "smb", "host": "nas.local", "share": "backups", "path": "softvisor", "username": "backup", "password": "smb-secret"})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_and_strategy_lifecycle_with_manual_run() {
|
||||
let app = test_app_with_admin().await;
|
||||
let token = common::login(&app, ADMIN, PW).await.access;
|
||||
|
||||
let res = post(&app, "/api/backups/targets", smb(), Some(&token)).await;
|
||||
assert_eq!(res.status, StatusCode::CREATED, "{}", res.json);
|
||||
let tid = res.json["id"].as_str().unwrap().to_string();
|
||||
assert!(res.json.get("password").is_none());
|
||||
assert_eq!(res.json["password_set"], true);
|
||||
assert_eq!(
|
||||
post(
|
||||
&app,
|
||||
&format!("/api/backups/targets/{tid}/test"),
|
||||
json!({}),
|
||||
Some(&token)
|
||||
)
|
||||
.await
|
||||
.status,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
let bad = json!({"name": "x", "kind": "ftp", "host": "", "path": ""});
|
||||
assert_eq!(
|
||||
post(&app, "/api/backups/targets", bad, Some(&token))
|
||||
.await
|
||||
.status,
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
|
||||
let strategy = json!({"name": "Gitea DB", "source": {"type": "postgres_dump", "namespace": "gitea", "pod": "gitea-postgresql-0"},
|
||||
"schedule": "0 0 2 * * *", "target_id": tid, "retention": 3, "passphrase": "a-long-passphrase!", "enabled": true});
|
||||
let res = post(&app, "/api/backups/strategies", strategy, Some(&token)).await;
|
||||
assert_eq!(res.status, StatusCode::CREATED, "{}", res.json);
|
||||
let sid = res.json["id"].as_str().unwrap().to_string();
|
||||
assert!(res.json.get("passphrase").is_none());
|
||||
assert_eq!(res.json["encrypted"], true);
|
||||
|
||||
// target in use -> 409
|
||||
assert_eq!(
|
||||
send_json(
|
||||
&app,
|
||||
"DELETE",
|
||||
&format!("/api/backups/targets/{tid}"),
|
||||
json!({}),
|
||||
Some(&token)
|
||||
)
|
||||
.await
|
||||
.status,
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
|
||||
let run = post(
|
||||
&app,
|
||||
&format!("/api/backups/strategies/{sid}/run"),
|
||||
json!({}),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(run.status, StatusCode::ACCEPTED, "{}", run.json);
|
||||
assert_eq!(run.json["kind"], "backup");
|
||||
let id = run.json["id"].as_str().unwrap();
|
||||
let mut status = String::new();
|
||||
for _ in 0..100 {
|
||||
let r = get(&app, &format!("/api/jobs/{id}"), Some(&token)).await;
|
||||
status = r.json["status"].as_str().unwrap().into();
|
||||
if status != "running" {
|
||||
assert_eq!(status, "success", "{}", r.json["log"]);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||
}
|
||||
assert_eq!(status, "success");
|
||||
|
||||
let recs = get(
|
||||
&app,
|
||||
&format!("/api/backups/strategies/{sid}/records"),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
let recs = recs.json.as_array().unwrap();
|
||||
assert_eq!(recs.len(), 1);
|
||||
assert!(recs[0]["filename"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with(".sql.gz.enc"));
|
||||
|
||||
let list = get(&app, "/api/backups/strategies", Some(&token)).await;
|
||||
assert_eq!(list.json[0]["target_name"], "NAS");
|
||||
assert_eq!(list.json[0]["last_backup"]["filename"], recs[0]["filename"]);
|
||||
|
||||
let upd = send_json(&app, "PUT", &format!("/api/backups/strategies/{sid}"), json!({"name": "Gitea DB", "source": {"type": "postgres_dump", "namespace": "gitea", "pod": "gitea-postgresql-0"},
|
||||
"schedule": "0 0 3 * * *", "target_id": tid, "retention": 3, "passphrase": "", "enabled": false}), Some(&token)).await;
|
||||
assert_eq!(upd.status, StatusCode::OK, "{}", upd.json);
|
||||
assert_eq!(upd.json["enabled"], false);
|
||||
assert_eq!(
|
||||
upd.json["encrypted"], true,
|
||||
"empty passphrase keeps the stored one"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
send_json(
|
||||
&app,
|
||||
"DELETE",
|
||||
&format!("/api/backups/strategies/{sid}"),
|
||||
json!({}),
|
||||
Some(&token)
|
||||
)
|
||||
.await
|
||||
.status,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
assert_eq!(
|
||||
send_json(
|
||||
&app,
|
||||
"DELETE",
|
||||
&format!("/api/backups/targets/{tid}"),
|
||||
json!({}),
|
||||
Some(&token)
|
||||
)
|
||||
.await
|
||||
.status,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backups_are_admin_only_for_writes() {
|
||||
let app = test_app_with_admin().await;
|
||||
let admin = common::login(&app, ADMIN, PW).await.access;
|
||||
post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&admin)).await;
|
||||
let user = common::login(&app, "u@x.de", "user-password-123")
|
||||
.await
|
||||
.access;
|
||||
assert_eq!(
|
||||
get(&app, "/api/backups/targets", Some(&user)).await.status,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
get(&app, "/api/backups/strategies", Some(&user))
|
||||
.await
|
||||
.status,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
post(&app, "/api/backups/targets", smb(), Some(&user))
|
||||
.await
|
||||
.status,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
get(&app, "/api/backups/targets", None).await.status,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
196
backend/crates/domain/src/backup.rs
Normal file
196
backend/crates/domain/src/backup.rs
Normal file
@ -0,0 +1,196 @@
|
||||
//! Backup targets (where), sources (what), strategies (when/how) and records (what was produced).
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DomainError;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StorageKind {
|
||||
Smb,
|
||||
Ftp,
|
||||
}
|
||||
|
||||
impl StorageKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
StorageKind::Smb => "smb",
|
||||
StorageKind::Ftp => "ftp",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"smb" => Some(StorageKind::Smb),
|
||||
"ftp" => Some(StorageKind::Ftp),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BackupTarget {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub kind: StorageKind,
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
/// SMB share name; unused for FTP.
|
||||
#[serde(default)]
|
||||
pub share: String,
|
||||
/// Directory on the share / server.
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
/// Plain text in memory, encrypted at rest.
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
/// FTPS (explicit TLS) for FTP targets.
|
||||
#[serde(default)]
|
||||
pub tls: bool,
|
||||
}
|
||||
|
||||
impl BackupTarget {
|
||||
pub fn validate(&self) -> Result<(), DomainError> {
|
||||
let err = |m: &str| Err(DomainError::Validation(m.into()));
|
||||
if self.name.trim().is_empty() {
|
||||
return err("target name is required");
|
||||
}
|
||||
if self.host.trim().is_empty() || self.host.contains(char::is_whitespace) {
|
||||
return err("host is invalid");
|
||||
}
|
||||
if self.kind == StorageKind::Smb && self.share.trim().is_empty() {
|
||||
return err("smb share is required");
|
||||
}
|
||||
if self.path.contains("..") {
|
||||
return err("path must not contain '..'");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum BackupSource {
|
||||
/// Content of a persistent volume claim (e.g. Gitea repositories, LFS, attachments).
|
||||
VolumeClaim { namespace: String, pvc: String },
|
||||
/// `pg_dumpall` executed inside the database pod.
|
||||
PostgresDump { namespace: String, pod: String },
|
||||
/// All resources of a namespace as YAML.
|
||||
KubernetesManifests { namespace: String },
|
||||
/// A directory on the host.
|
||||
HostPath { path: String },
|
||||
}
|
||||
|
||||
impl BackupSource {
|
||||
pub fn validate(&self) -> Result<(), DomainError> {
|
||||
let name = |s: &str, what: &str| {
|
||||
crate::cluster::validate_k8s_name(s)
|
||||
.map_err(|_| DomainError::Validation(format!("invalid {what}")))
|
||||
};
|
||||
match self {
|
||||
BackupSource::VolumeClaim { namespace, pvc } => {
|
||||
name(namespace, "namespace").and(name(pvc, "pvc"))
|
||||
}
|
||||
BackupSource::PostgresDump { namespace, pod } => {
|
||||
name(namespace, "namespace").and(name(pod, "pod"))
|
||||
}
|
||||
BackupSource::KubernetesManifests { namespace } => name(namespace, "namespace"),
|
||||
BackupSource::HostPath { path } => (path.starts_with('/') && !path.contains(".."))
|
||||
.then_some(())
|
||||
.ok_or_else(|| DomainError::Validation("host path must be absolute".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// File extension of the produced archive (before optional `.enc`).
|
||||
pub fn extension(&self) -> &'static str {
|
||||
match self {
|
||||
BackupSource::VolumeClaim { .. } | BackupSource::HostPath { .. } => "tar.gz",
|
||||
BackupSource::PostgresDump { .. } => "sql.gz",
|
||||
BackupSource::KubernetesManifests { .. } => "yaml.gz",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BackupStrategy {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub source: BackupSource,
|
||||
/// 6-field cron expression.
|
||||
pub schedule: String,
|
||||
pub target_id: Uuid,
|
||||
/// Keep the newest N backups on the target.
|
||||
pub retention: u32,
|
||||
/// Encrypt archives with this passphrase (AES-256, openssl compatible). Encrypted at rest.
|
||||
pub passphrase: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl BackupStrategy {
|
||||
pub fn validate(&self) -> Result<(), DomainError> {
|
||||
if self.name.trim().is_empty() {
|
||||
return Err(DomainError::Validation("strategy name is required".into()));
|
||||
}
|
||||
if !(1..=365).contains(&self.retention) {
|
||||
return Err(DomainError::Validation(
|
||||
"retention must be between 1 and 365".into(),
|
||||
));
|
||||
}
|
||||
if self
|
||||
.passphrase
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.chars().count() < 12)
|
||||
{
|
||||
return Err(DomainError::Validation(
|
||||
"passphrase must have at least 12 characters".into(),
|
||||
));
|
||||
}
|
||||
self.source.validate()
|
||||
}
|
||||
|
||||
/// Filename prefix on the target: lowercase name with non-alphanumerics replaced by '-'.
|
||||
pub fn slug(&self) -> String {
|
||||
let mut s: String = self
|
||||
.name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
while s.contains("--") {
|
||||
s = s.replace("--", "-");
|
||||
}
|
||||
s.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
pub fn filename(&self, at: DateTime<Utc>) -> String {
|
||||
let enc = if self.passphrase.is_some() {
|
||||
".enc"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"{}_{}.{}{enc}",
|
||||
self.slug(),
|
||||
at.format("%Y%m%d-%H%M%S"),
|
||||
self.source.extension()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BackupRecord {
|
||||
pub id: Uuid,
|
||||
pub strategy_id: Uuid,
|
||||
pub filename: String,
|
||||
pub size_bytes: u64,
|
||||
pub sha256: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RemoteFile {
|
||||
pub name: String,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
//! Domain layer: entities, value objects, errors and the ports (traits) the application
|
||||
//! layer depends on. No I/O here.
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod cluster;
|
||||
pub mod error;
|
||||
pub mod host;
|
||||
|
||||
@ -3,6 +3,7 @@ use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{AccessClaims, AuthEvent, RefreshToken};
|
||||
use crate::backup::{BackupRecord, BackupSource, BackupStrategy, BackupTarget, RemoteFile};
|
||||
use crate::cluster::{ClusterOverview, WorkloadRef};
|
||||
use crate::host::{Inventory, OsInfo, Package};
|
||||
use crate::jobs::{JobKind, JobRun, JobStatus};
|
||||
@ -10,6 +11,7 @@ use crate::settings::SmtpSettings;
|
||||
use crate::user::{User, UserUpdate};
|
||||
use crate::vuln::{Finding, FindingFilter, FindingStatus, RawFinding, SeverityCounts, TargetKind};
|
||||
use crate::DomainError;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserRepository: Send + Sync {
|
||||
@ -148,3 +150,68 @@ pub trait FindingRepository: Send + Sync {
|
||||
async fn list(&self, filter: &FindingFilter) -> Result<Vec<Finding>, DomainError>;
|
||||
async fn counts(&self, kind: Option<TargetKind>) -> Result<SeverityCounts, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BackupTargetRepository: Send + Sync {
|
||||
async fn list(&self) -> Result<Vec<BackupTarget>, DomainError>;
|
||||
async fn get(&self, id: Uuid) -> Result<Option<BackupTarget>, DomainError>;
|
||||
async fn insert(&self, t: &BackupTarget) -> Result<(), DomainError>;
|
||||
async fn update(&self, t: &BackupTarget) -> Result<(), DomainError>;
|
||||
async fn delete(&self, id: Uuid) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BackupStrategyRepository: Send + Sync {
|
||||
async fn list(&self) -> Result<Vec<BackupStrategy>, DomainError>;
|
||||
async fn get(&self, id: Uuid) -> Result<Option<BackupStrategy>, DomainError>;
|
||||
async fn insert(&self, s: &BackupStrategy) -> Result<(), DomainError>;
|
||||
async fn update(&self, s: &BackupStrategy) -> Result<(), DomainError>;
|
||||
async fn delete(&self, id: Uuid) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BackupRecordRepository: Send + Sync {
|
||||
async fn insert(&self, r: &BackupRecord) -> Result<(), DomainError>;
|
||||
/// Newest first.
|
||||
async fn list_for(
|
||||
&self,
|
||||
strategy_id: Uuid,
|
||||
limit: u32,
|
||||
) -> Result<Vec<BackupRecord>, DomainError>;
|
||||
async fn delete_by_filename(
|
||||
&self,
|
||||
strategy_id: Uuid,
|
||||
filename: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// Remote storage (SMB share or FTP server).
|
||||
#[async_trait]
|
||||
pub trait BackupStorage: Send + Sync {
|
||||
async fn test(&self, target: &BackupTarget) -> Result<(), DomainError>;
|
||||
async fn upload(
|
||||
&self,
|
||||
target: &BackupTarget,
|
||||
local: &Path,
|
||||
remote_name: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn list(&self, target: &BackupTarget) -> Result<Vec<RemoteFile>, DomainError>;
|
||||
async fn delete(&self, target: &BackupTarget, remote_name: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// Produces a compressed archive for a source in `work_dir`; returns the file path.
|
||||
#[async_trait]
|
||||
pub trait BackupCollector: Send + Sync {
|
||||
async fn collect(
|
||||
&self,
|
||||
source: &BackupSource,
|
||||
work_dir: &Path,
|
||||
out: &dyn LineSink,
|
||||
) -> Result<PathBuf, DomainError>;
|
||||
}
|
||||
|
||||
/// Encrypts a file with a passphrase; returns the encrypted file path.
|
||||
#[async_trait]
|
||||
pub trait FileEncryptor: Send + Sync {
|
||||
async fn encrypt(&self, input: &Path, passphrase: &str) -> Result<PathBuf, DomainError>;
|
||||
}
|
||||
|
||||
@ -104,6 +104,10 @@ impl TrivyScanner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Skipped by the OS scan: containerd/snap content (scanned per image instead) and pseudo filesystems.
|
||||
pub const ROOTFS_SKIP_DIRS: &str =
|
||||
"/var/snap/microk8s/common,/snap,/var/lib/snapd,/var/lib/containerd,/var/lib/docker,/proc,/sys,/dev,/run,/tmp";
|
||||
|
||||
const COMMON: [&str; 7] = [
|
||||
"--format",
|
||||
"json",
|
||||
@ -133,7 +137,8 @@ impl VulnerabilityScanner for TrivyScanner {
|
||||
async fn scan_os(&self, out: &dyn LineSink) -> Result<Vec<RawFinding>, DomainError> {
|
||||
let mut args = vec!["rootfs"];
|
||||
args.extend(COMMON);
|
||||
args.push("/");
|
||||
// container image layers, snaps and pseudo filesystems are not part of the host OS
|
||||
args.extend(["--skip-dirs", ROOTFS_SKIP_DIRS, "/"]);
|
||||
self.scan(&args, out).await
|
||||
}
|
||||
async fn scan_image(
|
||||
@ -287,6 +292,18 @@ mod tests {
|
||||
},
|
||||
"rootfs" | "image" => {
|
||||
assert!(args.contains(&"--format") && args.contains(&"json"));
|
||||
if args[0] == "rootfs" {
|
||||
let skip = args
|
||||
.iter()
|
||||
.position(|a| *a == "--skip-dirs")
|
||||
.map(|i| args[i + 1])
|
||||
.unwrap_or("");
|
||||
assert!(
|
||||
skip.contains("/var/snap/microk8s/common") && skip.contains("/proc"),
|
||||
"{skip}"
|
||||
);
|
||||
assert_eq!(*args.last().unwrap(), "/");
|
||||
}
|
||||
crate::host::Output {
|
||||
stdout: SAMPLE.into(),
|
||||
success: true,
|
||||
|
||||
39
frontend/e2e/backups.spec.ts
Normal file
39
frontend/e2e/backups.spec.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('admin creates a target and a strategy, runs it and sees the backup', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('Email').fill('admin@example.com')
|
||||
await page.getByLabel('Password').fill('admin-password-123')
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
await page.getByRole('link', { name: 'Backups' }).click()
|
||||
|
||||
await page.getByRole('button', { name: 'New target' }).click()
|
||||
const name = `NAS ${Date.now()}`
|
||||
await page.getByLabel('Name').fill(name)
|
||||
await page.getByLabel('Host').fill('nas.local')
|
||||
await page.getByLabel('Share').fill('backups')
|
||||
await page.getByLabel('Directory').fill('softvisor')
|
||||
await page.getByLabel('Username').fill('backup')
|
||||
await page.getByLabel('Password').fill('smb-secret')
|
||||
await page.getByRole('button', { name: 'Save target' }).click()
|
||||
const targetRow = page.getByRole('row', { name: new RegExp(name) })
|
||||
await expect(targetRow).toBeVisible()
|
||||
await targetRow.getByRole('button', { name: 'Test' }).click()
|
||||
await expect(page.getByRole('status')).toContainText('Connection ok')
|
||||
|
||||
await page.getByRole('button', { name: 'New strategy' }).click()
|
||||
await page.getByLabel('Strategy name').fill('Gitea database')
|
||||
await page.getByLabel('Source type').selectOption('postgres_dump')
|
||||
await page.getByLabel('Namespace').fill('gitea')
|
||||
await page.getByLabel('Pod').fill('gitea-postgresql-0')
|
||||
await page.getByLabel('Schedule').fill('0 0 2 * * *')
|
||||
await page.getByLabel('Target').selectOption({ label: name })
|
||||
await page.getByLabel('Keep last').fill('3')
|
||||
await page.getByRole('button', { name: 'Save strategy' }).click()
|
||||
const row = page.getByRole('row', { name: /Gitea database/ })
|
||||
await expect(row).toBeVisible()
|
||||
await row.getByRole('button', { name: 'Run now' }).click()
|
||||
await expect(row).toContainText(/gitea-database_.*\.sql\.gz/, { timeout: 20_000 })
|
||||
await row.getByRole('button', { name: 'History' }).click()
|
||||
await expect(page.getByTestId('backup-records')).toContainText('sql.gz')
|
||||
})
|
||||
@ -163,3 +163,71 @@ export interface VulnSummary {
|
||||
last_scan: string | null
|
||||
scanner: string
|
||||
}
|
||||
|
||||
export type StorageKind = 'smb' | 'ftp'
|
||||
|
||||
export interface BackupTargetView {
|
||||
id: string
|
||||
name: string
|
||||
kind: StorageKind
|
||||
host: string
|
||||
port: number | null
|
||||
share: string
|
||||
path: string
|
||||
username: string
|
||||
tls: boolean
|
||||
password_set: boolean
|
||||
}
|
||||
|
||||
export interface BackupTargetPayload {
|
||||
name: string
|
||||
kind: StorageKind
|
||||
host: string
|
||||
port: number | null
|
||||
share: string
|
||||
path: string
|
||||
username: string
|
||||
password: string
|
||||
tls: boolean
|
||||
}
|
||||
|
||||
export type BackupSource =
|
||||
| { type: 'volume_claim'; namespace: string; pvc: string }
|
||||
| { type: 'postgres_dump'; namespace: string; pod: string }
|
||||
| { type: 'kubernetes_manifests'; namespace: string }
|
||||
| { type: 'host_path'; path: string }
|
||||
|
||||
export interface BackupStrategyView {
|
||||
id: string
|
||||
name: string
|
||||
source: BackupSource
|
||||
schedule: string
|
||||
target_id: string
|
||||
retention: number
|
||||
encrypted: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface BackupStrategyPayload {
|
||||
name: string
|
||||
source: BackupSource
|
||||
schedule: string
|
||||
target_id: string
|
||||
retention: number
|
||||
passphrase: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface BackupRecord {
|
||||
id: string
|
||||
strategy_id: string
|
||||
filename: string
|
||||
size_bytes: number
|
||||
sha256: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface StrategyStatus extends BackupStrategyView {
|
||||
target_name: string
|
||||
last_backup: BackupRecord | null
|
||||
}
|
||||
|
||||
71
frontend/src/components/StrategyForm.test.ts
Normal file
71
frontend/src/components/StrategyForm.test.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import StrategyForm from './StrategyForm.vue'
|
||||
|
||||
const targets = [
|
||||
{ id: 't1', name: 'NAS' },
|
||||
{ id: 't2', name: 'FTP' },
|
||||
]
|
||||
|
||||
describe('StrategyForm', () => {
|
||||
it('builds a postgres dump strategy', async () => {
|
||||
const w = mount(StrategyForm, { props: { targets } })
|
||||
await w.find('input[name=name]').setValue('Gitea DB')
|
||||
await w.find('select[name=source_type]').setValue('postgres_dump')
|
||||
await w.find('input[name=namespace]').setValue('gitea')
|
||||
await w.find('input[name=pod]').setValue('gitea-postgresql-0')
|
||||
await w.find('input[name=schedule]').setValue('0 0 2 * * *')
|
||||
await w.find('select[name=target_id]').setValue('t2')
|
||||
await w.find('input[name=retention]').setValue('7')
|
||||
await w.find('input[name=passphrase]').setValue('a-long-passphrase!')
|
||||
await w.find('form').trigger('submit')
|
||||
expect(w.emitted('submit')![0][0]).toEqual({
|
||||
name: 'Gitea DB',
|
||||
source: { type: 'postgres_dump', namespace: 'gitea', pod: 'gitea-postgresql-0' },
|
||||
schedule: '0 0 2 * * *',
|
||||
target_id: 't2',
|
||||
retention: 7,
|
||||
passphrase: 'a-long-passphrase!',
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('switches source fields by type', async () => {
|
||||
const w = mount(StrategyForm, { props: { targets } })
|
||||
await w.find('select[name=source_type]').setValue('host_path')
|
||||
expect(w.find('input[name=path]').exists()).toBe(true)
|
||||
expect(w.find('input[name=namespace]').exists()).toBe(false)
|
||||
await w.find('select[name=source_type]').setValue('volume_claim')
|
||||
expect(w.find('input[name=pvc]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('sends null passphrase when empty on create and keeps it on edit', async () => {
|
||||
const w = mount(StrategyForm, { props: { targets } })
|
||||
await w.find('input[name=name]').setValue('x')
|
||||
await w.find('input[name=path]').exists()
|
||||
await w.find('select[name=source_type]').setValue('kubernetes_manifests')
|
||||
await w.find('input[name=namespace]').setValue('gitea')
|
||||
await w.find('form').trigger('submit')
|
||||
expect((w.emitted('submit')![0][0] as { passphrase: unknown }).passphrase).toBeNull()
|
||||
|
||||
const e = mount(StrategyForm, {
|
||||
props: {
|
||||
targets,
|
||||
current: {
|
||||
id: 's1',
|
||||
name: 'Old',
|
||||
source: { type: 'host_path', path: '/srv' },
|
||||
schedule: '0 0 1 * * *',
|
||||
target_id: 't1',
|
||||
retention: 2,
|
||||
encrypted: true,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(e.text()).toContain('leave empty to keep')
|
||||
await e.find('form').trigger('submit')
|
||||
const p = e.emitted('submit')![0][0] as { passphrase: unknown; enabled: boolean }
|
||||
expect(p.passphrase).toBe('')
|
||||
expect(p.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
57
frontend/src/components/TargetForm.test.ts
Normal file
57
frontend/src/components/TargetForm.test.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import TargetForm from './TargetForm.vue'
|
||||
|
||||
describe('TargetForm', () => {
|
||||
it('emits an smb target payload', async () => {
|
||||
const w = mount(TargetForm, { props: {} })
|
||||
await w.find('input[name=name]').setValue('NAS')
|
||||
await w.find('select[name=kind]').setValue('smb')
|
||||
await w.find('input[name=host]').setValue('nas.local')
|
||||
await w.find('input[name=share]').setValue('backups')
|
||||
await w.find('input[name=path]').setValue('softvisor')
|
||||
await w.find('input[name=username]').setValue('backup')
|
||||
await w.find('input[name=password]').setValue('secret')
|
||||
await w.find('form').trigger('submit')
|
||||
expect(w.emitted('submit')![0][0]).toEqual({
|
||||
name: 'NAS',
|
||||
kind: 'smb',
|
||||
host: 'nas.local',
|
||||
port: null,
|
||||
share: 'backups',
|
||||
path: 'softvisor',
|
||||
username: 'backup',
|
||||
password: 'secret',
|
||||
tls: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows ftp-only fields and hides share for ftp', async () => {
|
||||
const w = mount(TargetForm, { props: {} })
|
||||
await w.find('select[name=kind]').setValue('ftp')
|
||||
expect(w.find('input[name=share]').exists()).toBe(false)
|
||||
expect(w.find('input[name=tls]').exists()).toBe(true)
|
||||
expect(w.find('input[name=port]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('prefills an existing target and keeps the password empty', () => {
|
||||
const w = mount(TargetForm, {
|
||||
props: {
|
||||
current: {
|
||||
id: '1',
|
||||
name: 'NAS',
|
||||
kind: 'smb',
|
||||
host: 'nas.local',
|
||||
port: null,
|
||||
share: 'backups',
|
||||
path: 'x',
|
||||
username: 'u',
|
||||
tls: false,
|
||||
password_set: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect((w.find('input[name=host]').element as HTMLInputElement).value).toBe('nas.local')
|
||||
expect((w.find('input[name=password]').element as HTMLInputElement).value).toBe('')
|
||||
expect(w.text()).toContain('leave empty to keep')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user