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:
@ -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";
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -59,6 +59,51 @@ fn map_err(e: kube::Error) -> DomainError {
|
||||
}
|
||||
}
|
||||
|
||||
fn labels(meta: &kube::core::ObjectMeta) -> std::collections::BTreeMap<String, String> {
|
||||
meta.labels
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// PVCs referenced by the pod template, plus the claims a statefulset creates from its
|
||||
/// templates (`<template>-<statefulset>-<ordinal>`).
|
||||
fn claims(
|
||||
t: &Option<PodTemplateSpec>,
|
||||
templates: &[String],
|
||||
name: &str,
|
||||
all: &[PersistentVolumeClaim],
|
||||
) -> Vec<String> {
|
||||
let mut claims: Vec<String> = t
|
||||
.as_ref()
|
||||
.and_then(|t| t.spec.as_ref())
|
||||
.and_then(|s| s.volumes.as_ref())
|
||||
.map(|v| {
|
||||
v.iter()
|
||||
.filter_map(|v| {
|
||||
v.persistent_volume_claim
|
||||
.as_ref()
|
||||
.map(|c| c.claim_name.clone())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for template in templates {
|
||||
let prefix = format!("{template}-{name}-");
|
||||
claims.extend(
|
||||
all.iter()
|
||||
.filter_map(|p| p.metadata.name.clone())
|
||||
.filter(|n| {
|
||||
n.starts_with(&prefix) && n[prefix.len()..].chars().all(|c| c.is_ascii_digit())
|
||||
}),
|
||||
);
|
||||
}
|
||||
claims.sort();
|
||||
claims.dedup();
|
||||
claims
|
||||
}
|
||||
|
||||
fn containers(t: &Option<PodTemplateSpec>) -> Vec<Container> {
|
||||
t.as_ref()
|
||||
.and_then(|t| t.spec.as_ref())
|
||||
@ -154,6 +199,13 @@ impl ClusterGateway for KubeGateway {
|
||||
.unwrap_or(0),
|
||||
desired: d.spec.as_ref().and_then(|s| s.replicas).unwrap_or(0),
|
||||
containers: containers(&d.spec.as_ref().map(|s| s.template.clone())),
|
||||
labels: labels(&d.metadata),
|
||||
claims: claims(
|
||||
&d.spec.as_ref().map(|s| s.template.clone()),
|
||||
&[],
|
||||
"",
|
||||
&pvcs.items,
|
||||
),
|
||||
});
|
||||
}
|
||||
for s in statefulsets {
|
||||
@ -168,6 +220,21 @@ impl ClusterGateway for KubeGateway {
|
||||
.unwrap_or(0),
|
||||
desired: s.spec.as_ref().and_then(|s| s.replicas).unwrap_or(0),
|
||||
containers: containers(&s.spec.as_ref().map(|s| s.template.clone())),
|
||||
labels: labels(&s.metadata),
|
||||
claims: claims(
|
||||
&s.spec.as_ref().map(|s| s.template.clone()),
|
||||
&s.spec
|
||||
.as_ref()
|
||||
.and_then(|sp| sp.volume_claim_templates.as_ref())
|
||||
.map(|t| {
|
||||
t.iter()
|
||||
.filter_map(|t| t.metadata.name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
s.metadata.name.as_deref().unwrap_or_default(),
|
||||
&pvcs.items,
|
||||
),
|
||||
});
|
||||
}
|
||||
for d in daemonsets {
|
||||
@ -182,6 +249,8 @@ impl ClusterGateway for KubeGateway {
|
||||
.map(|s| s.desired_number_scheduled)
|
||||
.unwrap_or(0),
|
||||
containers: containers(&d.spec.as_ref().map(|s| s.template.clone())),
|
||||
labels: labels(&d.metadata),
|
||||
claims: Vec::new(),
|
||||
});
|
||||
}
|
||||
workloads.sort_by(|a, b| (&a.namespace, &a.name).cmp(&(&b.namespace, &b.name)));
|
||||
@ -284,6 +353,15 @@ impl ClusterGateway for FakeClusterGateway {
|
||||
image: image.into(),
|
||||
}]
|
||||
};
|
||||
let labeled = |instance: &str, role: &str| {
|
||||
[
|
||||
("app.kubernetes.io/instance", instance),
|
||||
("app.kubernetes.io/name", role),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>()
|
||||
};
|
||||
let w = |ns: &str, kind, name: &str, ready, desired, containers| Workload {
|
||||
namespace: ns.into(),
|
||||
kind,
|
||||
@ -291,6 +369,14 @@ impl ClusterGateway for FakeClusterGateway {
|
||||
ready,
|
||||
desired,
|
||||
containers,
|
||||
labels: Default::default(),
|
||||
claims: Vec::new(),
|
||||
};
|
||||
// the fake Gitea release mirrors the real one: one instance label, three roles
|
||||
let app = |ns: &str, kind, name: &str, role: &str, claims: &[&str], containers| Workload {
|
||||
labels: labeled("gitea", role),
|
||||
claims: claims.iter().map(|c| c.to_string()).collect(),
|
||||
..w(ns, kind, name, 1, 1, containers)
|
||||
};
|
||||
Ok(ClusterOverview {
|
||||
nodes: vec![NodeInfo {
|
||||
@ -319,28 +405,28 @@ impl ClusterGateway for FakeClusterGateway {
|
||||
"quay.io/jetstack/cert-manager-controller:v1.16.1",
|
||||
),
|
||||
),
|
||||
w(
|
||||
app(
|
||||
"gitea",
|
||||
WorkloadKind::Deployment,
|
||||
"gitea",
|
||||
1,
|
||||
1,
|
||||
"gitea",
|
||||
&["gitea-shared-storage"],
|
||||
c("gitea", "gitea/gitea:1.22.3"),
|
||||
),
|
||||
w(
|
||||
app(
|
||||
"gitea",
|
||||
WorkloadKind::StatefulSet,
|
||||
"gitea-postgresql",
|
||||
1,
|
||||
1,
|
||||
"postgresql",
|
||||
&["data-gitea-postgresql-0"],
|
||||
c("postgresql", "bitnami/postgresql:16.4.0"),
|
||||
),
|
||||
w(
|
||||
app(
|
||||
"gitea",
|
||||
WorkloadKind::StatefulSet,
|
||||
"gitea-valkey-primary",
|
||||
1,
|
||||
1,
|
||||
"valkey",
|
||||
&["valkey-data-gitea-valkey-primary-0"],
|
||||
c("valkey", "bitnami/valkey:8.0.1"),
|
||||
),
|
||||
w(
|
||||
|
||||
Reference in New Issue
Block a user