WP-30/31/32: backup targets, strategies and execution
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:
735
backend/crates/infrastructure/src/backup.rs
Normal file
735
backend/crates/infrastructure/src/backup.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user