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

@ -1,9 +1,10 @@
//! /api/backups: targets, strategies, records, manual runs.
use application::backup_service::StrategyStatus;
use application::backup_service::{ApplicationBackupPlan, StrategyStatus};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use domain::application::Application;
use domain::backup::{BackupRecord, BackupSource, BackupStrategy, BackupTarget, StorageKind};
use domain::jobs::JobKind;
use serde::{Deserialize, Serialize};
@ -31,6 +32,55 @@ pub fn router() -> Router<AppState> {
)
.route("/strategies/{id}/run", post(run_strategy))
.route("/strategies/{id}/records", get(records))
.route(
"/applications",
get(list_applications).post(create_from_application),
)
}
// ---- applications ----
#[derive(Deserialize, ToSchema)]
pub struct ApplicationBackupRequest {
pub application_id: String,
pub component_ids: Vec<String>,
pub schedule: String,
pub target_id: Uuid,
pub retention: u32,
#[serde(default)]
pub passphrase: Option<String>,
}
#[utoipa::path(get, path = "/api/backups/applications", tag = "backups", security(("bearer" = [])),
responses((status = 200, body = Vec<Object>)))]
async fn list_applications(
State(s): State<AppState>,
_: AuthUser,
) -> Result<Json<Vec<Application>>, ApiError> {
Ok(Json(s.backups.applications().await?))
}
#[utoipa::path(post, path = "/api/backups/applications", tag = "backups", security(("bearer" = [])),
request_body = ApplicationBackupRequest, responses((status = 201, body = Vec<StrategyView>), (status = 404), (status = 422)))]
async fn create_from_application(
State(s): State<AppState>,
_: AdminUser,
Json(req): Json<ApplicationBackupRequest>,
) -> Result<(StatusCode, Json<Vec<StrategyView>>), ApiError> {
let plan = ApplicationBackupPlan {
schedule: req.schedule,
target_id: req.target_id,
retention: req.retention,
passphrase: req.passphrase.filter(|p| !p.is_empty()),
};
let created = s
.backups
.create_from_application(&req.application_id, &req.component_ids, plan)
.await?;
Ok((
StatusCode::CREATED,
Json(created.into_iter().map(Into::into).collect()),
))
}
// ---- targets ----

View File

@ -130,6 +130,7 @@ impl AppState {
} = adapters;
let pool_for_findings = pool.clone();
let cluster_gateway = cluster.clone();
let cluster_gateway_for_backups = cluster.clone();
let users = Arc::new(SqliteUsers(pool.clone()));
let hasher = Arc::new(Argon2Hasher);
let auth = AuthService::new(
@ -141,6 +142,8 @@ impl AppState {
);
let cipher = Arc::new(AesGcmCipher::from_hex(&cfg.master_key)?);
let backups = Arc::new(BackupService::new(BackupDeps {
cluster: cluster_gateway_for_backups,
host: inspector.clone(),
targets: Arc::new(SqliteBackupTargets(pool.clone())),
strategies: Arc::new(SqliteBackupStrategies(pool.clone())),
records: Arc::new(SqliteBackupRecords(pool.clone())),

View File

@ -34,6 +34,7 @@ impl Modify for BearerAuth {
crate::backups::delete_target, crate::backups::test_target, crate::backups::list_strategies, crate::backups::get_strategy,
crate::backups::create_strategy, crate::backups::update_strategy, crate::backups::delete_strategy,
crate::backups::run_strategy, crate::backups::records,
crate::backups::list_applications, crate::backups::create_from_application,
crate::dashboard::dashboard,
),
modifiers(&BearerAuth)

View File

@ -166,3 +166,64 @@ async fn backups_are_admin_only_for_writes() {
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn applications_are_listed_and_can_be_backed_up_in_one_step() {
let app = test_app_with_admin().await;
let token = common::login(&app, ADMIN, PW).await.access;
let target = post(&app, "/api/backups/targets", smb(), Some(&token)).await;
let tid = target.json["id"].as_str().unwrap().to_string();
let res = get(&app, "/api/backups/applications", Some(&token)).await;
assert_eq!(res.status, StatusCode::OK, "{}", res.json);
let apps = res.json.as_array().unwrap();
let gitea = apps
.iter()
.find(|a| a["name"] == "Gitea")
.expect("the gitea release");
assert_eq!(gitea["kind"], "kubernetes");
assert_eq!(gitea["namespace"], "gitea");
let components = gitea["components"].as_array().unwrap();
let data = components
.iter()
.find(|c| c["id"] == "data:gitea-shared-storage")
.unwrap();
assert_eq!(data["kind"], "data");
assert_eq!(data["recommended"], true);
assert!(components
.iter()
.any(|c| c["id"] == "db:gitea-postgresql-0" && c["kind"] == "database"));
assert!(apps.iter().any(|a| a["kind"] == "host"));
// "Gitea → data + database → 3am → target → save"
let body = json!({
"application_id": gitea["id"],
"component_ids": ["data:gitea-shared-storage", "db:gitea-postgresql-0"],
"schedule": "0 0 3 * * *",
"target_id": tid,
"retention": 7
});
let res = post(&app, "/api/backups/applications", body, Some(&token)).await;
assert_eq!(res.status, StatusCode::CREATED, "{}", res.json);
let created = res.json.as_array().unwrap();
assert_eq!(created.len(), 2);
assert!(created[0]["name"].as_str().unwrap().starts_with("Gitea – "));
assert_eq!(created[0]["schedule"], "0 0 3 * * *");
let strategies = get(&app, "/api/backups/strategies", Some(&token)).await;
assert_eq!(strategies.json.as_array().unwrap().len(), 2);
// a viewer may look but not create
post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&token)).await;
let user = common::login(&app, "u@x.de", "user-password-123")
.await
.access;
assert_eq!(
get(&app, "/api/backups/applications", Some(&user))
.await
.status,
StatusCode::OK
);
let res = post(&app, "/api/backups/applications", json!({"application_id": "x", "component_ids": [], "schedule": "0 0 3 * * *", "target_id": tid, "retention": 7}), Some(&user)).await;
assert_eq!(res.status, StatusCode::FORBIDDEN);
}