Files
Infrastruktur-Monitoring-Sy…/backend/crates/domain/src/backup.rs
Dennis Nemec 39af18b336
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled
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>
2026-09-02 23:05:22 +02:00

197 lines
6.0 KiB
Rust

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