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

@ -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);
}