Discover applications on the cluster and the host for backups

Kubernetes workloads are grouped by their Helm instance label into
applications, each offering what is worth backing up: data volumes, a
PostgreSQL dump instead of the database's own volume, and optionally the
namespace manifests. Caches are listed but not preselected. Host
applications come from running systemd services that declare a state or
working directory. Selecting components creates one strategy each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-03 20:50:11 +02:00
parent 4efff51aa7
commit 51364fdd76
16 changed files with 1050 additions and 17 deletions

View File

@ -2,7 +2,7 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::host::{OsInfo, Package, PackageSource};
use domain::host::{HostService, OsInfo, Package, PackageSource};
use domain::ports::HostInspector;
use domain::DomainError;
@ -39,6 +39,50 @@ impl HostInspector for DebianInspector {
})
}
async fn services(&self) -> Result<Vec<HostService>, DomainError> {
let out = self
.runner
.run(
"systemctl",
&[
"list-units",
"--type=service",
"--state=running",
"--no-legend",
"--no-pager",
],
)
.await?;
if !out.success {
return Err(DomainError::Unavailable("systemctl failed".into()));
}
let mut services = Vec::new();
for (unit, description) in parse_service_units(&out.stdout) {
let props = self
.runner
.run(
"systemctl",
&[
"show",
&unit,
"-p",
"WorkingDirectory",
"-p",
"StateDirectory",
],
)
.await?;
let (working_dir, state_dir) = parse_service_properties(&props.stdout);
services.push(HostService {
unit,
description,
working_dir,
state_dir,
});
}
Ok(services)
}
async fn packages(&self) -> Result<Vec<Package>, DomainError> {
let r = &self.runner;
let dpkg = r
@ -139,6 +183,34 @@ pub fn parse_snap_list(out: &str) -> Vec<(String, String)> {
.collect()
}
/// `systemctl list-units --type=service --state=running --no-legend` → (unit, description).
pub fn parse_service_units(out: &str) -> Vec<(String, String)> {
out.lines()
.filter_map(|l| {
let mut parts = l.split_whitespace();
let unit = parts.next()?.to_string();
if !unit.ends_with(".service") {
return None;
}
// loaded / active / running, then the description
let description = parts.skip(3).collect::<Vec<_>>().join(" ");
Some((unit, description))
})
.collect()
}
/// `systemctl show -p WorkingDirectory -p StateDirectory` → (working dir, state dir).
pub fn parse_service_properties(out: &str) -> (Option<String>, Option<String>) {
let value = |key: &str| {
out.lines()
.find_map(|l| l.strip_prefix(&format!("{key}=")))
.map(str::trim)
.filter(|v| !v.is_empty())
.map(String::from)
};
(value("WorkingDirectory"), value("StateDirectory"))
}
/// `/etc/os-release` → (PRETTY_NAME, VERSION_ID).
pub fn parse_os_release(content: &str) -> (String, String) {
let get = |key: &str| {
@ -194,6 +266,36 @@ Conf openssl (3.0.16-1~deb12u1 Debian-Security:12/stable-security [amd64])\n";
);
}
#[test]
fn reads_running_services_and_their_directories() {
// `systemctl list-units --type=service --state=running --no-legend`
let units = " monitoring.service loaded active running SoftVisor Infrastructure Monitoring\n ssh.service loaded active running OpenBSD Secure Shell server\n";
assert_eq!(
parse_service_units(units),
vec![
(
"monitoring.service".to_string(),
"SoftVisor Infrastructure Monitoring".to_string()
),
(
"ssh.service".to_string(),
"OpenBSD Secure Shell server".to_string()
)
]
);
// `systemctl show -p WorkingDirectory -p StateDirectory <unit>`
let props = "WorkingDirectory=/opt/monitoring\nStateDirectory=\n";
assert_eq!(
parse_service_properties(props),
(Some("/opt/monitoring".to_string()), None)
);
assert_eq!(
parse_service_properties("WorkingDirectory=\nStateDirectory=tailscale\n"),
(None, Some("tailscale".to_string()))
);
assert_eq!(parse_service_properties(""), (None, None));
}
#[test]
fn os_release_strips_quotes() {
let c = "PRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"\nNAME=\"Debian GNU/Linux\"\nVERSION_ID=\"12\"\n";

View File

@ -1,6 +1,6 @@
//! Sample data for development machines without apt (FAKE_HOST=true).
use async_trait::async_trait;
use domain::host::{OsInfo, Package, PackageSource};
use domain::host::{HostService, OsInfo, Package, PackageSource};
use domain::ports::HostInspector;
use domain::DomainError;
@ -19,6 +19,23 @@ impl HostInspector for FakeHostInspector {
})
}
async fn services(&self) -> Result<Vec<HostService>, DomainError> {
Ok(vec![
HostService {
unit: "monitoring.service".into(),
description: "SoftVisor Infrastructure Monitoring".into(),
working_dir: Some("/opt/monitoring".into()),
state_dir: None,
},
HostService {
unit: "ssh.service".into(),
description: "OpenBSD Secure Shell server".into(),
working_dir: None,
state_dir: None,
},
])
}
async fn packages(&self) -> Result<Vec<Package>, DomainError> {
let apt = |n: &str, i: &str, c: Option<&str>, s: bool| Package {
name: n.into(),