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