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