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,13 +2,15 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use chrono::{Duration, TimeZone, Utc};
use domain::application::ApplicationKind;
use domain::backup::{BackupSource, StorageKind};
use domain::DomainError;
use crate::backup_service::ApplicationBackupPlan;
use crate::jobs::{JobHandler, JobLog};
use crate::test_fakes::{
strategy, target, FakeCipher, FakeCollector, FakeEncryptor, MemRecords, MemStorage,
MemStrategies, MemTargets,
strategy, target, FakeCipher, FakeCollector, FakeEncryptor, FakeInspector, MemCluster,
MemRecords, MemStorage, MemStrategies, MemTargets,
};
use crate::{BackupDeps, BackupJob, BackupService};
@ -39,6 +41,8 @@ fn fixture(fail_storage: bool) -> (F, Arc<BackupService>) {
});
let work = tempfile::tempdir().unwrap();
let svc = BackupService::new(BackupDeps {
cluster: Arc::new(MemCluster::default()),
host: Arc::new(FakeInspector { fail: false }),
targets: targets.clone(),
strategies: strategies.clone(),
records: records.clone(),
@ -372,3 +376,121 @@ async fn backup_job_runs_strategy_from_params() {
.unwrap_err()
.contains("not found"));
}
#[tokio::test]
async fn applications_are_discovered_from_the_cluster_and_the_host() {
let (_f, svc) = fixture(false);
let apps = svc.applications().await.unwrap();
let gitea = apps
.iter()
.find(|a| a.name == "Gitea")
.expect("the gitea release");
assert_eq!(gitea.kind, ApplicationKind::Kubernetes);
assert_eq!(gitea.namespace.as_deref(), Some("gitea"));
let ids: Vec<&str> = gitea.components.iter().map(|c| c.id.as_str()).collect();
assert!(ids.contains(&"data:gitea-shared-storage"), "{ids:?}");
assert!(ids.contains(&"db:gitea-postgresql-0"), "{ids:?}");
assert!(ids.contains(&"manifests"), "{ids:?}");
let host = apps
.iter()
.find(|a| a.kind == ApplicationKind::Host)
.expect("a host application");
assert_eq!(host.name, "Monitoring");
assert_eq!(host.components.len(), 1);
}
#[tokio::test]
async fn a_selection_of_components_becomes_one_strategy_each() {
let (f, svc) = fixture(false);
let target = svc.create_target(target("NAS")).await.unwrap();
let apps = svc.applications().await.unwrap();
let gitea = apps.iter().find(|a| a.name == "Gitea").unwrap();
let created = svc
.create_from_application(
&gitea.id,
&[
"data:gitea-shared-storage".into(),
"db:gitea-postgresql-0".into(),
],
ApplicationBackupPlan {
schedule: "0 0 3 * * *".into(),
target_id: target.id,
retention: 7,
passphrase: None,
},
)
.await
.unwrap();
assert_eq!(created.len(), 2);
assert_eq!(
created[0].name,
"Gitea – Gitea volume gitea-shared-storage (10Gi)"
);
assert_eq!(created[0].schedule, "0 0 3 * * *");
assert_eq!(created[0].target_id, target.id);
assert_eq!(created[0].retention, 7);
assert!(created[0].enabled);
assert_eq!(
created[0].source,
BackupSource::VolumeClaim {
namespace: "gitea".into(),
pvc: "gitea-shared-storage".into()
}
);
assert_eq!(
created[1].source,
BackupSource::PostgresDump {
namespace: "gitea".into(),
pod: "gitea-postgresql-0".into()
}
);
assert_eq!(f.strategies.0.lock().unwrap().len(), 2, "both are stored");
// the strategies are ordinary ones from here on
let listed = svc.list_strategies().await.unwrap();
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].target_name, "NAS");
}
#[tokio::test]
async fn creating_from_an_application_validates_its_input() {
let (_f, svc) = fixture(false);
let target = svc.create_target(target("NAS")).await.unwrap();
let plan = |schedule: &str| ApplicationBackupPlan {
schedule: schedule.into(),
target_id: target.id,
retention: 7,
passphrase: None,
};
let apps = svc.applications().await.unwrap();
let gitea = apps.iter().find(|a| a.name == "Gitea").unwrap().id.clone();
assert_eq!(
svc.create_from_application("k8s:nope/nope", &["x".into()], plan("0 0 3 * * *"))
.await
.unwrap_err(),
DomainError::NotFound
);
assert!(matches!(
svc.create_from_application(&gitea, &[], plan("0 0 3 * * *"))
.await
.unwrap_err(),
DomainError::Validation(_)
));
assert_eq!(
svc.create_from_application(&gitea, &["data:nothing".into()], plan("0 0 3 * * *"))
.await
.unwrap_err(),
DomainError::NotFound
);
assert!(matches!(
svc.create_from_application(&gitea, &["manifests".into()], plan("not a cron"))
.await
.unwrap_err(),
DomainError::Validation(_)
));
}