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

@ -23,6 +23,7 @@ kube = { version = "0.99", default-features = false, features = ["client", "rust
k8s-openapi = { version = "0.24", features = ["v1_32"] }
[dev-dependencies]
tempfile = "3"
tokio.workspace = true
[lints]

View File

@ -0,0 +1,33 @@
CREATE TABLE backup_targets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('smb', 'ftp')),
host TEXT NOT NULL,
port INTEGER,
share TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
tls INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE backup_strategies (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
source TEXT NOT NULL,
schedule TEXT NOT NULL,
target_id TEXT NOT NULL REFERENCES backup_targets(id),
retention INTEGER NOT NULL,
passphrase TEXT,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE backup_records (
id TEXT PRIMARY KEY,
strategy_id TEXT NOT NULL REFERENCES backup_strategies(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
sha256 TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX backup_records_strategy ON backup_records(strategy_id, created_at DESC);

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)
}