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,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,
}

View File

@ -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;

View File

@ -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>;
}