WP-30/31/32: backup targets, strategies and execution
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

SMB (smbclient) and FTP/FTPS (curl with netrc) targets with encrypted
credentials and connection test; strategies with cron schedule, retention,
optional openssl AES-256 encryption; sources: PVC hostpath tar, pg_dumpall
in the Postgres pod, namespace manifests, host directory. Backup job
collects, encrypts, uploads, verifies size, records sha256 and applies
retention on the target; scheduler starts due strategies. Backups page
with target/strategy forms, run now and history. Restore guide in docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 23:15:38 +02:00
parent 39af18b336
commit 6134a47ff2
31 changed files with 2718 additions and 87 deletions

View File

@ -0,0 +1,735 @@
//! Backup adapters: remote storage via smbclient/curl, collectors via kubectl/tar, openssl encryption,
//! and file-based fakes for development.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use domain::backup::{BackupSource, BackupTarget, RemoteFile, StorageKind};
use domain::ports::{BackupCollector, BackupStorage, FileEncryptor, LineSink};
use domain::DomainError;
use crate::host::CommandRunner;
fn unavailable(what: &str, out: &crate::host::Output) -> DomainError {
let tail: Vec<&str> = out
.stderr
.lines()
.chain(out.stdout.lines())
.rev()
.take(3)
.collect();
DomainError::Unavailable(format!(
"{what}: {}",
tail.into_iter().rev().collect::<Vec<_>>().join(" | ")
))
}
fn join_path(dir: &str, name: &str) -> String {
let dir = dir.trim_matches('/');
if dir.is_empty() {
name.to_string()
} else {
format!("{dir}/{name}")
}
}
// ---------------------------------------------------------------- storage
pub struct CommandBackupStorage {
runner: Arc<dyn CommandRunner>,
}
impl CommandBackupStorage {
pub fn new(runner: Arc<dyn CommandRunner>) -> Self {
Self { runner }
}
async fn smb(
&self,
t: &BackupTarget,
command: &str,
) -> Result<crate::host::Output, DomainError> {
let service = format!("//{}/{}", t.host, t.share);
let mut args = vec![
service.as_str(),
"-U",
if t.username.is_empty() {
"guest"
} else {
t.username.as_str()
},
];
let port;
if let Some(p) = t.port {
port = p.to_string();
args.extend(["-p", port.as_str()]);
}
if t.username.is_empty() {
args.push("-N");
}
let dir;
if !t.path.trim_matches('/').is_empty() {
dir = t.path.trim_matches('/').to_string();
args.extend(["-D", dir.as_str()]);
}
args.extend(["-c", command]);
self.runner
.run_env("smbclient", &args, &[("PASSWD", t.password.as_str())])
.await
}
fn ftp_url(t: &BackupTarget, name: &str) -> String {
let port = t.port.map(|p| format!(":{p}")).unwrap_or_default();
let path = join_path(&t.path, name);
format!("ftp://{}{port}/{path}", t.host)
}
async fn curl(
&self,
t: &BackupTarget,
extra: &[&str],
) -> Result<crate::host::Output, DomainError> {
// credentials via a netrc file so they never appear in the process list
let netrc = std::env::temp_dir().join(format!("monitoring-netrc-{}", uuid::Uuid::new_v4()));
std::fs::write(
&netrc,
format!(
"machine {} login {} password {}\n",
t.host, t.username, t.password
),
)
.map_err(|e| DomainError::Storage(e.to_string()))?;
let _ =
std::fs::set_permissions(&netrc, std::os::unix::fs::PermissionsExt::from_mode(0o600));
let netrc_s = netrc.to_string_lossy().to_string();
let mut args = vec!["-sS", "--fail", "--netrc-file", netrc_s.as_str()];
if t.tls {
args.push("--ssl-reqd");
}
args.extend(extra);
let res = self.runner.run("curl", &args).await;
let _ = std::fs::remove_file(&netrc);
res
}
}
/// `smbclient -c ls` lines: ` name A 1234 Tue Sep 2 ...`
pub fn parse_smb_ls(out: &str) -> Vec<RemoteFile> {
out.lines()
.filter_map(|l| {
let l = l.trim_end();
if !l.starts_with(" ") || l.contains("blocks of size") {
return None;
}
// attributes column is a short uppercase token ("A", "D", "AH"...), size follows it
let parts: Vec<&str> = l.split_whitespace().collect();
let idx = parts
.iter()
.position(|p| p.len() <= 3 && p.chars().all(|c| c.is_ascii_uppercase()))?;
if idx == 0 {
return None;
}
let name = parts[..idx].join(" ");
if name == "." || name == ".." || parts[idx].contains('D') {
return None;
}
let size = parts.get(idx + 1)?.parse().ok()?;
Some(RemoteFile {
name,
size_bytes: size,
})
})
.collect()
}
/// Unix-style FTP `LIST` lines: `-rw-r--r-- 1 u g 1234 Sep 2 10:00 name`
pub fn parse_ftp_list(out: &str) -> Vec<RemoteFile> {
out.lines()
.filter_map(|l| {
let parts: Vec<&str> = l.split_whitespace().collect();
if parts.len() < 9 || !parts[0].starts_with('-') {
return None;
}
Some(RemoteFile {
name: parts[8..].join(" "),
size_bytes: parts[4].parse().ok()?,
})
})
.collect()
}
#[async_trait]
impl BackupStorage for CommandBackupStorage {
async fn test(&self, t: &BackupTarget) -> Result<(), DomainError> {
self.list(t).await.map(|_| ())
}
async fn upload(
&self,
t: &BackupTarget,
local: &Path,
remote_name: &str,
) -> Result<(), DomainError> {
let local_s = local.to_string_lossy().to_string();
let out = match t.kind {
StorageKind::Smb => {
self.smb(t, &format!("put \"{local_s}\" \"{remote_name}\""))
.await?
}
StorageKind::Ftp => {
self.curl(
t,
&[
"--ftp-create-dirs",
"-T",
&local_s,
&Self::ftp_url(t, remote_name),
],
)
.await?
}
};
out.success
.then_some(())
.ok_or_else(|| unavailable("upload failed", &out))
}
async fn list(&self, t: &BackupTarget) -> Result<Vec<RemoteFile>, DomainError> {
let out = match t.kind {
StorageKind::Smb => self.smb(t, "ls").await?,
StorageKind::Ftp => self.curl(t, &[&Self::ftp_url(t, "")]).await?,
};
if !out.success {
return Err(unavailable("listing failed", &out));
}
Ok(match t.kind {
StorageKind::Smb => parse_smb_ls(&out.stdout),
StorageKind::Ftp => parse_ftp_list(&out.stdout),
})
}
async fn delete(&self, t: &BackupTarget, remote_name: &str) -> Result<(), DomainError> {
let out = match t.kind {
StorageKind::Smb => self.smb(t, &format!("del \"{remote_name}\"")).await?,
StorageKind::Ftp => {
let dele = format!("DELE {}", join_path(&t.path, remote_name));
self.curl(
t,
&[
"-Q",
&dele,
&Self::ftp_url(
&BackupTarget {
path: String::new(),
..t.clone()
},
"",
),
],
)
.await?
}
};
out.success
.then_some(())
.ok_or_else(|| unavailable("delete failed", &out))
}
}
// ---------------------------------------------------------------- collector
pub struct KubeBackupCollector {
runner: Arc<dyn CommandRunner>,
/// kubectl invocation, e.g. `["/snap/bin/microk8s", "kubectl"]`.
kubectl: Vec<String>,
}
impl KubeBackupCollector {
pub fn new(runner: Arc<dyn CommandRunner>, kubectl: Vec<String>) -> Self {
Self { runner, kubectl }
}
async fn kubectl(&self, args: &[&str]) -> Result<crate::host::Output, DomainError> {
let mut all: Vec<&str> = self.kubectl.iter().skip(1).map(String::as_str).collect();
all.extend(args);
self.runner.run(&self.kubectl[0], &all).await
}
async fn kubectl_to_file(
&self,
args: &[&str],
file: &Path,
) -> Result<crate::host::Output, DomainError> {
let mut all: Vec<&str> = self.kubectl.iter().skip(1).map(String::as_str).collect();
all.extend(args);
self.runner.run_to_file(&self.kubectl[0], &all, file).await
}
async fn tar_dir(&self, dir: &str, file: &Path, out: &dyn LineSink) -> Result<(), DomainError> {
let f = file.to_string_lossy().to_string();
out.line(&format!("$ tar -czf {f} -C {dir} ."));
let res = self
.runner
.run("tar", &["-czf", &f, "-C", dir, "."])
.await?;
res.success
.then_some(())
.ok_or_else(|| unavailable("tar failed", &res))
}
async fn gzip(&self, file: &Path) -> Result<PathBuf, DomainError> {
let f = file.to_string_lossy().to_string();
let res = self.runner.run("gzip", &["-f", &f]).await?;
res.success
.then_some(file.with_extension(format!(
"{}.gz",
file.extension().and_then(|e| e.to_str()).unwrap_or("")
)))
.ok_or_else(|| unavailable("gzip failed", &res))
}
}
#[async_trait]
impl BackupCollector for KubeBackupCollector {
async fn collect(
&self,
source: &BackupSource,
work_dir: &Path,
out: &dyn LineSink,
) -> Result<PathBuf, DomainError> {
match source {
BackupSource::VolumeClaim { namespace, pvc } => {
out.line(&format!("resolving host path of pvc {namespace}/{pvc}"));
let pv = self
.kubectl(&[
"get",
"pvc",
"-n",
namespace,
pvc,
"-o",
"jsonpath={.spec.volumeName}",
])
.await?;
if !pv.success || pv.stdout.trim().is_empty() {
return Err(unavailable("pvc lookup failed", &pv));
}
let path = self
.kubectl(&[
"get",
"pv",
pv.stdout.trim(),
"-o",
"jsonpath={.spec.hostPath.path}",
])
.await?;
let dir = path.stdout.trim().to_string();
if !path.success || dir.is_empty() {
return Err(DomainError::Unavailable(
"pv has no hostPath (only hostpath volumes are supported)".into(),
));
}
let file = work_dir.join("volume.tar.gz");
self.tar_dir(&dir, &file, out).await?;
Ok(file)
}
BackupSource::PostgresDump { namespace, pod } => {
out.line(&format!(
"$ kubectl exec -n {namespace} {pod} -- pg_dumpall"
));
let raw = work_dir.join("dump.sql");
let res = self
.kubectl_to_file(&["exec", "-n", namespace, pod, "--", "sh", "-c", "PGPASSWORD=\"${POSTGRES_PASSWORD:-$POSTGRESQL_PASSWORD}\" pg_dumpall -U postgres"], &raw)
.await?;
if !res.success {
return Err(unavailable("pg_dumpall failed", &res));
}
self.gzip(&raw).await
}
BackupSource::KubernetesManifests { namespace } => {
out.line(&format!("$ kubectl get all,configmap,secret,ingress,pvc,serviceaccount -n {namespace} -o yaml"));
let raw = work_dir.join("manifests.yaml");
let res = self
.kubectl_to_file(
&[
"get",
"all,configmap,secret,ingress,pvc,serviceaccount",
"-n",
namespace,
"-o",
"yaml",
],
&raw,
)
.await?;
if !res.success {
return Err(unavailable("kubectl get failed", &res));
}
self.gzip(&raw).await
}
BackupSource::HostPath { path } => {
let file = work_dir.join("hostpath.tar.gz");
self.tar_dir(path, &file, out).await?;
Ok(file)
}
}
}
}
// ---------------------------------------------------------------- encryption
/// `openssl enc -aes-256-cbc -pbkdf2`; decrypt with
/// `openssl enc -d -aes-256-cbc -pbkdf2 -in FILE.enc -out FILE`.
pub struct OpensslEncryptor {
runner: Arc<dyn CommandRunner>,
}
impl OpensslEncryptor {
pub fn new(runner: Arc<dyn CommandRunner>) -> Self {
Self { runner }
}
}
#[async_trait]
impl FileEncryptor for OpensslEncryptor {
async fn encrypt(&self, input: &Path, passphrase: &str) -> Result<PathBuf, DomainError> {
let out = PathBuf::from(format!("{}.enc", input.to_string_lossy()));
let (i, o) = (
input.to_string_lossy().to_string(),
out.to_string_lossy().to_string(),
);
let res = self
.runner
.run_env(
"openssl",
&[
"enc",
"-aes-256-cbc",
"-pbkdf2",
"-salt",
"-in",
&i,
"-out",
&o,
"-pass",
"env:BACKUP_PASSPHRASE",
],
&[("BACKUP_PASSPHRASE", passphrase)],
)
.await?;
if !res.success {
return Err(unavailable("openssl enc failed", &res));
}
let _ = std::fs::remove_file(input);
Ok(out)
}
}
// ---------------------------------------------------------------- fakes
/// Stores "remote" files in a local directory per target (FAKE_HOST=true).
pub struct DirBackupStorage {
root: PathBuf,
}
impl DirBackupStorage {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
fn dir(&self, t: &BackupTarget) -> Result<PathBuf, DomainError> {
let d = self.root.join(t.id.to_string());
std::fs::create_dir_all(&d).map_err(|e| DomainError::Storage(e.to_string()))?;
Ok(d)
}
}
#[async_trait]
impl BackupStorage for DirBackupStorage {
async fn test(&self, t: &BackupTarget) -> Result<(), DomainError> {
if t.host.contains("unreachable") {
return Err(DomainError::Unavailable("connection refused".into()));
}
self.dir(t).map(|_| ())
}
async fn upload(
&self,
t: &BackupTarget,
local: &Path,
remote_name: &str,
) -> Result<(), DomainError> {
std::fs::copy(local, self.dir(t)?.join(remote_name))
.map(|_| ())
.map_err(|e| DomainError::Storage(e.to_string()))
}
async fn list(&self, t: &BackupTarget) -> Result<Vec<RemoteFile>, DomainError> {
let mut v = Vec::new();
for e in std::fs::read_dir(self.dir(t)?)
.map_err(|e| DomainError::Storage(e.to_string()))?
.flatten()
{
if let Ok(m) = e.metadata() {
v.push(RemoteFile {
name: e.file_name().to_string_lossy().to_string(),
size_bytes: m.len(),
});
}
}
Ok(v)
}
async fn delete(&self, t: &BackupTarget, remote_name: &str) -> Result<(), DomainError> {
std::fs::remove_file(self.dir(t)?.join(remote_name))
.map_err(|e| DomainError::Storage(e.to_string()))
}
}
/// Produces a small archive describing the source (FAKE_HOST=true).
pub struct FakeBackupCollector;
#[async_trait]
impl BackupCollector for FakeBackupCollector {
async fn collect(
&self,
source: &BackupSource,
work_dir: &Path,
out: &dyn LineSink,
) -> Result<PathBuf, DomainError> {
out.line(&format!(
"fake: collecting {}",
serde_json::to_string(source).unwrap_or_default()
));
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let file = work_dir.join(format!("archive.{}", source.extension()));
std::fs::write(&file, format!("fake backup of {source:?}\n"))
.map_err(|e| DomainError::Storage(e.to_string()))?;
Ok(file)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn parses_smbclient_listing() {
let out = " . D 0 Tue Sep 2 10:00:00 2026\n .. D 0 Tue Sep 2 10:00:00 2026\n gitea-db_20260902-020000.sql.gz A 12345 Tue Sep 2 02:00:05 2026\n my file.tar.gz A 99 Tue Sep 2 02:00:05 2026\n\n\t\t1234567 blocks of size 1024. 999 blocks available\n";
let files = parse_smb_ls(out);
assert_eq!(
files,
vec![
RemoteFile {
name: "gitea-db_20260902-020000.sql.gz".into(),
size_bytes: 12345
},
RemoteFile {
name: "my file.tar.gz".into(),
size_bytes: 99
}
]
);
}
#[test]
fn parses_ftp_listing() {
let out = "drwxr-xr-x 2 ftp ftp 4096 Sep 02 02:00 sub\n-rw-r--r-- 1 ftp ftp 12345 Sep 02 02:00 gitea-db_20260902-020000.sql.gz\n";
assert_eq!(
parse_ftp_list(out),
vec![RemoteFile {
name: "gitea-db_20260902-020000.sql.gz".into(),
size_bytes: 12345
}]
);
}
type Call = (String, Vec<String>, Vec<(String, String)>);
#[derive(Default)]
struct Rec(Mutex<Vec<Call>>);
#[async_trait]
impl CommandRunner for Rec {
async fn run(&self, p: &str, a: &[&str]) -> Result<crate::host::Output, DomainError> {
self.run_env(p, a, &[]).await
}
async fn run_env(
&self,
p: &str,
a: &[&str],
env: &[(&str, &str)],
) -> Result<crate::host::Output, DomainError> {
self.0.lock().unwrap().push((
p.into(),
a.iter().map(|s| s.to_string()).collect(),
env.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
));
Ok(crate::host::Output {
stdout: "pvc-123\n".into(),
success: true,
..Default::default()
})
}
async fn run_to_file(
&self,
p: &str,
a: &[&str],
f: &Path,
) -> Result<crate::host::Output, DomainError> {
std::fs::write(f, "data").unwrap();
self.run_env(p, a, &[]).await
}
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
Ok(None)
}
async fn run_streaming(
&self,
_: &str,
_: &[&str],
_: &dyn LineSink,
) -> Result<bool, DomainError> {
Ok(true)
}
}
struct Sink;
impl LineSink for Sink {
fn line(&self, _: &str) {}
}
fn smb_target() -> BackupTarget {
BackupTarget {
id: uuid::Uuid::new_v4(),
name: "n".into(),
kind: StorageKind::Smb,
host: "nas".into(),
port: None,
share: "backups".into(),
path: "softvisor".into(),
username: "u".into(),
password: "p".into(),
tls: false,
}
}
#[tokio::test]
async fn smb_upload_passes_password_via_env_not_argv() {
let r = Arc::new(Rec::default());
CommandBackupStorage::new(r.clone())
.upload(&smb_target(), Path::new("/tmp/x.tar.gz"), "x.tar.gz")
.await
.unwrap();
let calls = r.0.lock().unwrap();
let (prog, args, env) = &calls[0];
assert_eq!(prog, "smbclient");
assert_eq!(args[0], "//nas/backups");
assert!(args.contains(&"-D".to_string()) && args.contains(&"softvisor".to_string()));
assert!(args.iter().any(|a| a.starts_with("put ")), "{args:?}");
assert!(!args.iter().any(|a| a.contains('p') && a.len() == 1));
assert_eq!(env[0], ("PASSWD".to_string(), "p".to_string()));
}
#[tokio::test]
async fn ftp_uses_curl_with_netrc_and_tls_flag() {
let r = Arc::new(Rec::default());
let mut t = smb_target();
t.kind = StorageKind::Ftp;
t.tls = true;
t.port = Some(2121);
CommandBackupStorage::new(r.clone())
.upload(&t, Path::new("/tmp/x"), "x")
.await
.unwrap();
let calls = r.0.lock().unwrap();
let (prog, args, _) = &calls[0];
assert_eq!(prog, "curl");
assert!(args.contains(&"--netrc-file".to_string()));
assert!(args.contains(&"--ssl-reqd".to_string()));
assert!(
args.last().unwrap().ends_with("ftp://nas:2121/softvisor/x"),
"{args:?}"
);
assert!(
!args.iter().any(|a| a.contains("u:p")),
"no credentials in argv"
);
}
#[tokio::test]
async fn collector_builds_kubectl_and_tar_commands() {
let r = Arc::new(Rec::default());
let c = KubeBackupCollector::new(
r.clone(),
vec!["/snap/bin/microk8s".into(), "kubectl".into()],
);
let work = tempfile::tempdir().unwrap();
let f = c
.collect(
&BackupSource::VolumeClaim {
namespace: "gitea".into(),
pvc: "data".into(),
},
work.path(),
&Sink,
)
.await
.unwrap();
assert!(f.ends_with("volume.tar.gz"));
let f = c
.collect(
&BackupSource::PostgresDump {
namespace: "gitea".into(),
pod: "pg-0".into(),
},
work.path(),
&Sink,
)
.await
.unwrap();
assert!(f.ends_with("dump.sql.gz"), "{f:?}");
let calls = r.0.lock().unwrap();
assert_eq!(calls[0].0, "/snap/bin/microk8s");
assert_eq!(calls[0].1[..3], ["kubectl", "get", "pvc"]);
assert_eq!(calls[2].0, "tar");
let exec = calls
.iter()
.find(|c| c.1.contains(&"exec".to_string()))
.unwrap();
assert!(exec.1.iter().any(|a| a.contains("pg_dumpall")));
assert!(calls.iter().any(|c| c.0 == "gzip"));
}
#[tokio::test]
async fn openssl_receives_passphrase_via_env() {
let r = Arc::new(Rec::default());
let work = tempfile::tempdir().unwrap();
let input = work.path().join("a.tar.gz");
std::fs::write(&input, "x").unwrap();
let out = OpensslEncryptor::new(r.clone())
.encrypt(&input, "pw")
.await
.unwrap();
assert!(out.to_string_lossy().ends_with("a.tar.gz.enc"));
let calls = r.0.lock().unwrap();
assert_eq!(calls[0].0, "openssl");
assert!(calls[0].1.contains(&"env:BACKUP_PASSPHRASE".to_string()));
assert_eq!(calls[0].2[0].1, "pw");
}
#[tokio::test]
async fn dir_storage_roundtrip() {
let root = tempfile::tempdir().unwrap();
let s = DirBackupStorage::new(root.path().to_path_buf());
let t = smb_target();
let local = root.path().join("local.bin");
std::fs::write(&local, "hello").unwrap();
s.upload(&t, &local, "remote.bin").await.unwrap();
assert_eq!(
s.list(&t).await.unwrap(),
vec![RemoteFile {
name: "remote.bin".into(),
size_bytes: 5
}]
);
s.delete(&t, "remote.bin").await.unwrap();
assert!(s.list(&t).await.unwrap().is_empty());
}
}

View File

@ -13,6 +13,20 @@ pub struct Output {
pub trait CommandRunner: Send + Sync {
async fn run(&self, program: &str, args: &[&str]) -> Result<Output, DomainError>;
async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError>;
/// Run with extra environment variables (used to pass secrets without exposing them in argv).
async fn run_env(
&self,
program: &str,
args: &[&str],
env: &[(&str, &str)],
) -> Result<Output, DomainError>;
/// Run a command and write its stdout to `file`; stderr is captured in the result.
async fn run_to_file(
&self,
program: &str,
args: &[&str],
file: &std::path::Path,
) -> Result<Output, DomainError>;
/// Run a command and forward each output line (stdout and stderr) to `out`.
async fn run_streaming(
&self,
@ -27,10 +41,24 @@ pub struct SystemCommandRunner;
#[async_trait]
impl CommandRunner for SystemCommandRunner {
async fn run(&self, program: &str, args: &[&str]) -> Result<Output, DomainError> {
let out = tokio::process::Command::new(program)
.args(args)
self.run_env(program, args, &[]).await
}
async fn run_env(
&self,
program: &str,
args: &[&str],
env: &[(&str, &str)],
) -> Result<Output, DomainError> {
let mut cmd = tokio::process::Command::new(program);
cmd.args(args)
.env("DEBIAN_FRONTEND", "noninteractive")
.env("LC_ALL", "C")
.stdin(std::process::Stdio::null());
for (k, v) in env {
cmd.env(k, v);
}
let out = cmd
.output()
.await
.map_err(|e| DomainError::Unavailable(format!("{program}: {e}")))?;
@ -41,6 +69,30 @@ impl CommandRunner for SystemCommandRunner {
})
}
async fn run_to_file(
&self,
program: &str,
args: &[&str],
file: &std::path::Path,
) -> Result<Output, DomainError> {
let f = std::fs::File::create(file)
.map_err(|e| DomainError::Storage(format!("{}: {e}", file.display())))?;
let out = tokio::process::Command::new(program)
.args(args)
.env("LC_ALL", "C")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(f))
.stderr(std::process::Stdio::piped())
.output()
.await
.map_err(|e| DomainError::Unavailable(format!("{program}: {e}")))?;
Ok(Output {
stdout: String::new(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
success: out.status.success(),
})
}
async fn run_streaming(
&self,
program: &str,

View File

@ -237,6 +237,22 @@ Conf openssl (3.0.16-1~deb12u1 Debian-Security:12/stable-security [amd64])\n";
) -> Result<bool, DomainError> {
unreachable!()
}
async fn run_env(
&self,
p: &str,
a: &[&str],
_: &[(&str, &str)],
) -> Result<super::super::Output, DomainError> {
self.run(p, a).await
}
async fn run_to_file(
&self,
_: &str,
_: &[&str],
_: &std::path::Path,
) -> Result<super::super::Output, DomainError> {
unreachable!()
}
async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError> {
Ok(match path {
"/etc/os-release" => Some(

View File

@ -79,6 +79,22 @@ mod tests {
..Default::default()
})
}
async fn run_env(
&self,
p: &str,
a: &[&str],
_: &[(&str, &str)],
) -> Result<crate::host::Output, DomainError> {
self.run(p, a).await
}
async fn run_to_file(
&self,
_: &str,
_: &[&str],
_: &std::path::Path,
) -> Result<crate::host::Output, DomainError> {
unreachable!()
}
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
Ok(None)
}

View File

@ -1,4 +1,5 @@
//! Infrastructure layer: SQLite repositories, Argon2 hashing, JWT issuing.
pub mod backup;
pub mod cipher;
pub mod db;
pub mod host;
@ -9,6 +10,10 @@ pub mod sqlite;
pub mod token;
pub mod trivy;
pub use backup::{
CommandBackupStorage, DirBackupStorage, FakeBackupCollector, KubeBackupCollector,
OpensslEncryptor,
};
pub use cipher::AesGcmCipher;
pub use db::{connect, DbPool};
pub use host::{
@ -18,6 +23,9 @@ pub use k8s::{FakeClusterGateway, KubeGateway};
pub use mail::LettreMailer;
pub use password::Argon2Hasher;
pub use sqlite::{SqliteAuditLog, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers};
pub use sqlite::{SqliteFindings, SqliteInventory};
pub use sqlite::{
SqliteBackupRecords, SqliteBackupStrategies, SqliteBackupTargets, SqliteFindings,
SqliteInventory,
};
pub use token::JwtIssuer;
pub use trivy::{FakeScanner, TrivyScanner};

View File

@ -753,3 +753,307 @@ mod finding_tests {
);
}
}
use domain::backup::{BackupRecord, BackupSource, BackupStrategy, BackupTarget, StorageKind};
pub struct SqliteBackupTargets(pub DbPool);
fn target_from_row(r: &SqliteRow) -> BackupTarget {
BackupTarget {
id: r.get("id"),
name: r.get("name"),
kind: StorageKind::parse(r.get::<String, _>("kind").as_str()).unwrap_or(StorageKind::Smb),
host: r.get("host"),
port: r.get::<Option<i64>, _>("port").map(|p| p as u16),
share: r.get("share"),
path: r.get("path"),
username: r.get("username"),
password: r.get("password"),
tls: r.get("tls"),
}
}
const TARGET_COLS: &str = "id, name, kind, host, port, share, path, username, password, tls";
#[async_trait]
impl domain::ports::BackupTargetRepository for SqliteBackupTargets {
async fn list(&self) -> Result<Vec<BackupTarget>, DomainError> {
sqlx::query(&format!(
"SELECT {TARGET_COLS} FROM backup_targets ORDER BY name"
))
.fetch_all(&self.0)
.await
.map(|rows| rows.iter().map(target_from_row).collect())
.map_err(storage)
}
async fn get(&self, id: Uuid) -> Result<Option<BackupTarget>, DomainError> {
sqlx::query(&format!(
"SELECT {TARGET_COLS} FROM backup_targets WHERE id = ?"
))
.bind(id)
.fetch_optional(&self.0)
.await
.map(|r| r.as_ref().map(target_from_row))
.map_err(storage)
}
async fn insert(&self, t: &BackupTarget) -> Result<(), DomainError> {
sqlx::query(&format!(
"INSERT INTO backup_targets ({TARGET_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
))
.bind(t.id)
.bind(&t.name)
.bind(t.kind.as_str())
.bind(&t.host)
.bind(t.port.map(|p| p as i64))
.bind(&t.share)
.bind(&t.path)
.bind(&t.username)
.bind(&t.password)
.bind(t.tls)
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
async fn update(&self, t: &BackupTarget) -> Result<(), DomainError> {
let res = sqlx::query("UPDATE backup_targets SET name = ?, kind = ?, host = ?, port = ?, share = ?, path = ?, username = ?, password = ?, tls = ? WHERE id = ?")
.bind(&t.name)
.bind(t.kind.as_str())
.bind(&t.host)
.bind(t.port.map(|p| p as i64))
.bind(&t.share)
.bind(&t.path)
.bind(&t.username)
.bind(&t.password)
.bind(t.tls)
.bind(t.id)
.execute(&self.0)
.await
.map_err(storage)?;
(res.rows_affected() > 0)
.then_some(())
.ok_or(DomainError::NotFound)
}
async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
let res = sqlx::query("DELETE FROM backup_targets WHERE id = ?")
.bind(id)
.execute(&self.0)
.await
.map_err(storage)?;
(res.rows_affected() > 0)
.then_some(())
.ok_or(DomainError::NotFound)
}
}
pub struct SqliteBackupStrategies(pub DbPool);
fn strategy_from_row(r: &SqliteRow) -> Result<BackupStrategy, DomainError> {
Ok(BackupStrategy {
id: r.get("id"),
name: r.get("name"),
source: serde_json::from_str::<BackupSource>(r.get::<String, _>("source").as_str())
.map_err(|e| DomainError::Storage(e.to_string()))?,
schedule: r.get("schedule"),
target_id: r.get("target_id"),
retention: r.get::<i64, _>("retention") as u32,
passphrase: r.get("passphrase"),
enabled: r.get("enabled"),
})
}
const STRATEGY_COLS: &str = "id, name, source, schedule, target_id, retention, passphrase, enabled";
#[async_trait]
impl domain::ports::BackupStrategyRepository for SqliteBackupStrategies {
async fn list(&self) -> Result<Vec<BackupStrategy>, DomainError> {
let rows = sqlx::query(&format!(
"SELECT {STRATEGY_COLS} FROM backup_strategies ORDER BY name"
))
.fetch_all(&self.0)
.await
.map_err(storage)?;
rows.iter().map(strategy_from_row).collect()
}
async fn get(&self, id: Uuid) -> Result<Option<BackupStrategy>, DomainError> {
let row = sqlx::query(&format!(
"SELECT {STRATEGY_COLS} FROM backup_strategies WHERE id = ?"
))
.bind(id)
.fetch_optional(&self.0)
.await
.map_err(storage)?;
row.as_ref().map(strategy_from_row).transpose()
}
async fn insert(&self, s: &BackupStrategy) -> Result<(), DomainError> {
let source =
serde_json::to_string(&s.source).map_err(|e| DomainError::Storage(e.to_string()))?;
sqlx::query(&format!(
"INSERT INTO backup_strategies ({STRATEGY_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
))
.bind(s.id)
.bind(&s.name)
.bind(source)
.bind(&s.schedule)
.bind(s.target_id)
.bind(s.retention as i64)
.bind(&s.passphrase)
.bind(s.enabled)
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
async fn update(&self, s: &BackupStrategy) -> Result<(), DomainError> {
let source =
serde_json::to_string(&s.source).map_err(|e| DomainError::Storage(e.to_string()))?;
let res = sqlx::query("UPDATE backup_strategies SET name = ?, source = ?, schedule = ?, target_id = ?, retention = ?, passphrase = ?, enabled = ? WHERE id = ?")
.bind(&s.name)
.bind(source)
.bind(&s.schedule)
.bind(s.target_id)
.bind(s.retention as i64)
.bind(&s.passphrase)
.bind(s.enabled)
.bind(s.id)
.execute(&self.0)
.await
.map_err(storage)?;
(res.rows_affected() > 0)
.then_some(())
.ok_or(DomainError::NotFound)
}
async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
let res = sqlx::query("DELETE FROM backup_strategies WHERE id = ?")
.bind(id)
.execute(&self.0)
.await
.map_err(storage)?;
(res.rows_affected() > 0)
.then_some(())
.ok_or(DomainError::NotFound)
}
}
pub struct SqliteBackupRecords(pub DbPool);
#[async_trait]
impl domain::ports::BackupRecordRepository for SqliteBackupRecords {
async fn insert(&self, r: &BackupRecord) -> Result<(), DomainError> {
sqlx::query("INSERT INTO backup_records (id, strategy_id, filename, size_bytes, sha256, created_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(r.id)
.bind(r.strategy_id)
.bind(&r.filename)
.bind(r.size_bytes as i64)
.bind(&r.sha256)
.bind(r.created_at.to_rfc3339())
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
async fn list_for(
&self,
strategy_id: Uuid,
limit: u32,
) -> Result<Vec<BackupRecord>, DomainError> {
sqlx::query("SELECT id, strategy_id, filename, size_bytes, sha256, created_at FROM backup_records WHERE strategy_id = ? ORDER BY created_at DESC LIMIT ?")
.bind(strategy_id)
.bind(limit)
.fetch_all(&self.0)
.await
.map(|rows| {
rows.iter()
.map(|r| BackupRecord {
id: r.get("id"),
strategy_id: r.get("strategy_id"),
filename: r.get("filename"),
size_bytes: r.get::<i64, _>("size_bytes") as u64,
sha256: r.get("sha256"),
created_at: parse_ts(r.get::<String, _>("created_at").as_str()),
})
.collect()
})
.map_err(storage)
}
async fn delete_by_filename(
&self,
strategy_id: Uuid,
filename: &str,
) -> Result<(), DomainError> {
sqlx::query("DELETE FROM backup_records WHERE strategy_id = ? AND filename = ?")
.bind(strategy_id)
.bind(filename)
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
}
#[cfg(test)]
mod backup_tests {
use super::*;
use domain::ports::{BackupRecordRepository, BackupStrategyRepository, BackupTargetRepository};
#[tokio::test]
async fn targets_strategies_records_roundtrip() {
let pool = crate::connect("sqlite::memory:").await.unwrap();
let targets = SqliteBackupTargets(pool.clone());
let strategies = SqliteBackupStrategies(pool.clone());
let records = SqliteBackupRecords(pool);
let t = BackupTarget {
id: Uuid::new_v4(),
name: "NAS".into(),
kind: StorageKind::Ftp,
host: "h".into(),
port: Some(2121),
share: String::new(),
path: "p".into(),
username: "u".into(),
password: "enc".into(),
tls: true,
};
targets.insert(&t).await.unwrap();
assert_eq!(targets.get(t.id).await.unwrap().unwrap(), t);
let s = BackupStrategy {
id: Uuid::new_v4(),
name: "S".into(),
source: BackupSource::HostPath {
path: "/srv".into(),
},
schedule: "0 0 1 * * *".into(),
target_id: t.id,
retention: 3,
passphrase: Some("enc".into()),
enabled: true,
};
strategies.insert(&s).await.unwrap();
assert_eq!(strategies.list().await.unwrap(), vec![s.clone()]);
let mut s2 = s.clone();
s2.enabled = false;
strategies.update(&s2).await.unwrap();
assert!(!strategies.get(s.id).await.unwrap().unwrap().enabled);
let r = BackupRecord {
id: Uuid::new_v4(),
strategy_id: s.id,
filename: "s_1.tar.gz".into(),
size_bytes: 10,
sha256: "x".into(),
created_at: Utc::now(),
};
records.insert(&r).await.unwrap();
assert_eq!(records.list_for(s.id, 10).await.unwrap().len(), 1);
records
.delete_by_filename(s.id, "s_1.tar.gz")
.await
.unwrap();
assert!(records.list_for(s.id, 10).await.unwrap().is_empty());
strategies.delete(s.id).await.unwrap();
targets.delete(t.id).await.unwrap();
assert_eq!(
targets.delete(t.id).await.unwrap_err(),
DomainError::NotFound
);
}
}

View File

@ -317,6 +317,22 @@ mod tests {
},
})
}
async fn run_env(
&self,
p: &str,
a: &[&str],
_: &[(&str, &str)],
) -> Result<crate::host::Output, DomainError> {
self.run(p, a).await
}
async fn run_to_file(
&self,
_: &str,
_: &[&str],
_: &std::path::Path,
) -> Result<crate::host::Output, DomainError> {
unreachable!()
}
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
Ok(None)
}