//! 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 { 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, /// 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, 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) -> 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, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RemoteFile { pub name: String, pub size_bytes: u64, }