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:
@ -4,10 +4,11 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::application::Application;
|
||||
use domain::backup::{BackupRecord, BackupStrategy, BackupTarget};
|
||||
use domain::ports::{
|
||||
BackupCollector, BackupRecordRepository, BackupStorage, BackupStrategyRepository,
|
||||
BackupTargetRepository, Cipher, FileEncryptor,
|
||||
BackupTargetRepository, Cipher, ClusterGateway, FileEncryptor, HostInspector,
|
||||
};
|
||||
use domain::DomainError;
|
||||
use sha2::{Digest, Sha256};
|
||||
@ -16,7 +17,18 @@ use uuid::Uuid;
|
||||
use crate::jobs::{JobHandler, JobLog};
|
||||
use crate::scheduler::{is_due, validate_cron};
|
||||
|
||||
/// Schedule, target and retention chosen for an application backup.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ApplicationBackupPlan {
|
||||
pub schedule: String,
|
||||
pub target_id: Uuid,
|
||||
pub retention: u32,
|
||||
pub passphrase: Option<String>,
|
||||
}
|
||||
|
||||
pub struct BackupDeps {
|
||||
pub cluster: Arc<dyn ClusterGateway>,
|
||||
pub host: Arc<dyn HostInspector>,
|
||||
pub targets: Arc<dyn BackupTargetRepository>,
|
||||
pub strategies: Arc<dyn BackupStrategyRepository>,
|
||||
pub records: Arc<dyn BackupRecordRepository>,
|
||||
@ -213,6 +225,58 @@ impl BackupService {
|
||||
self.d.strategies.delete(id).await
|
||||
}
|
||||
|
||||
/// Applications found on the cluster and on the host, with what they offer to back up.
|
||||
/// An unreachable cluster or host does not fail the call; that source is then missing.
|
||||
pub async fn applications(&self) -> Result<Vec<Application>, DomainError> {
|
||||
let mut apps = match self.d.cluster.overview().await {
|
||||
Ok(o) => domain::application::from_cluster(&o),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
if let Ok(services) = self.d.host.services().await {
|
||||
apps.extend(domain::application::from_services(&services));
|
||||
}
|
||||
Ok(apps)
|
||||
}
|
||||
|
||||
/// Turn the selected components of an application into one strategy each.
|
||||
pub async fn create_from_application(
|
||||
&self,
|
||||
application_id: &str,
|
||||
component_ids: &[String],
|
||||
plan: ApplicationBackupPlan,
|
||||
) -> Result<Vec<BackupStrategy>, DomainError> {
|
||||
if component_ids.is_empty() {
|
||||
return Err(DomainError::Validation(
|
||||
"select at least one component".into(),
|
||||
));
|
||||
}
|
||||
let apps = self.applications().await?;
|
||||
let app = apps
|
||||
.iter()
|
||||
.find(|a| a.id == application_id)
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
let mut created = Vec::new();
|
||||
for id in component_ids {
|
||||
let component = app
|
||||
.components
|
||||
.iter()
|
||||
.find(|c| &c.id == id)
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
let strategy = BackupStrategy {
|
||||
id: Uuid::new_v4(),
|
||||
name: format!("{} – {}", app.name, component.label),
|
||||
source: component.source.clone(),
|
||||
schedule: plan.schedule.clone(),
|
||||
target_id: plan.target_id,
|
||||
retention: plan.retention,
|
||||
passphrase: plan.passphrase.clone(),
|
||||
enabled: true,
|
||||
};
|
||||
created.push(self.create_strategy(strategy).await?);
|
||||
}
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
pub async fn records(&self, strategy_id: Uuid) -> Result<Vec<BackupRecord>, DomainError> {
|
||||
self.d.records.list_for(strategy_id, 100).await
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ pub mod user_service;
|
||||
pub mod vuln_service;
|
||||
|
||||
pub use auth_service::AuthService;
|
||||
pub use backup_service::{BackupDeps, BackupJob, BackupService};
|
||||
pub use backup_service::{ApplicationBackupPlan, BackupDeps, BackupJob, BackupService};
|
||||
pub use cluster_service::ClusterService;
|
||||
pub use image_update_service::{ImageUpdateCheckJob, ImageUpdateService};
|
||||
pub use inventory_service::{InventoryService, PackageRefreshJob};
|
||||
|
||||
@ -338,6 +338,22 @@ impl HostInspector for FakeInspector {
|
||||
reboot_required: true,
|
||||
})
|
||||
}
|
||||
async fn services(&self) -> Result<Vec<domain::host::HostService>, DomainError> {
|
||||
Ok(vec![
|
||||
domain::host::HostService {
|
||||
unit: "monitoring.service".into(),
|
||||
description: "SoftVisor Infrastructure Monitoring".into(),
|
||||
working_dir: Some("/opt/monitoring".into()),
|
||||
state_dir: None,
|
||||
},
|
||||
domain::host::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> {
|
||||
Ok(vec![
|
||||
Package {
|
||||
@ -421,6 +437,16 @@ pub struct MemCluster {
|
||||
pub fail: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
fn helm_labels(instance: &str, role: &str) -> std::collections::BTreeMap<String, String> {
|
||||
[
|
||||
("app.kubernetes.io/instance", instance),
|
||||
("app.kubernetes.io/name", role),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sample_overview() -> ClusterOverview {
|
||||
ClusterOverview {
|
||||
nodes: vec![NodeInfo {
|
||||
@ -443,6 +469,8 @@ pub fn sample_overview() -> ClusterOverview {
|
||||
name: "gitea".into(),
|
||||
image: "gitea/gitea:1.22.3".into(),
|
||||
}],
|
||||
labels: helm_labels("gitea", "gitea"),
|
||||
claims: vec!["gitea-shared-storage".into()],
|
||||
},
|
||||
Workload {
|
||||
namespace: "gitea".into(),
|
||||
@ -454,6 +482,8 @@ pub fn sample_overview() -> ClusterOverview {
|
||||
name: "postgresql".into(),
|
||||
image: "bitnami/postgresql:16.4.0".into(),
|
||||
}],
|
||||
labels: helm_labels("gitea", "postgresql"),
|
||||
claims: vec!["data-gitea-postgresql-0".into()],
|
||||
},
|
||||
],
|
||||
volume_claims: vec![VolumeClaim {
|
||||
|
||||
@ -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(_)
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user