Files
Dennis Nemec 51364fdd76 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>
2026-09-03 20:50:11 +02:00

230 lines
7.9 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! WP-30/31/32: /api/backups
mod common;
use axum::http::StatusCode;
use common::{get, post, send_json, test_app_with_admin};
use serde_json::json;
const ADMIN: &str = "admin@example.com";
const PW: &str = "admin-password-123";
fn smb() -> serde_json::Value {
json!({"name": "NAS", "kind": "smb", "host": "nas.local", "share": "backups", "path": "softvisor", "username": "backup", "password": "smb-secret"})
}
#[tokio::test]
async fn target_and_strategy_lifecycle_with_manual_run() {
let app = test_app_with_admin().await;
let token = common::login(&app, ADMIN, PW).await.access;
let res = post(&app, "/api/backups/targets", smb(), Some(&token)).await;
assert_eq!(res.status, StatusCode::CREATED, "{}", res.json);
let tid = res.json["id"].as_str().unwrap().to_string();
assert!(res.json.get("password").is_none());
assert_eq!(res.json["password_set"], true);
assert_eq!(
post(
&app,
&format!("/api/backups/targets/{tid}/test"),
json!({}),
Some(&token)
)
.await
.status,
StatusCode::NO_CONTENT
);
let bad = json!({"name": "x", "kind": "ftp", "host": "", "path": ""});
assert_eq!(
post(&app, "/api/backups/targets", bad, Some(&token))
.await
.status,
StatusCode::UNPROCESSABLE_ENTITY
);
let strategy = json!({"name": "Gitea DB", "source": {"type": "postgres_dump", "namespace": "gitea", "pod": "gitea-postgresql-0"},
"schedule": "0 0 2 * * *", "target_id": tid, "retention": 3, "passphrase": "a-long-passphrase!", "enabled": true});
let res = post(&app, "/api/backups/strategies", strategy, Some(&token)).await;
assert_eq!(res.status, StatusCode::CREATED, "{}", res.json);
let sid = res.json["id"].as_str().unwrap().to_string();
assert!(res.json.get("passphrase").is_none());
assert_eq!(res.json["encrypted"], true);
// target in use -> 409
assert_eq!(
send_json(
&app,
"DELETE",
&format!("/api/backups/targets/{tid}"),
json!({}),
Some(&token)
)
.await
.status,
StatusCode::CONFLICT
);
let run = post(
&app,
&format!("/api/backups/strategies/{sid}/run"),
json!({}),
Some(&token),
)
.await;
assert_eq!(run.status, StatusCode::ACCEPTED, "{}", run.json);
assert_eq!(run.json["kind"], "backup");
let id = run.json["id"].as_str().unwrap();
let mut status = String::new();
for _ in 0..100 {
let r = get(&app, &format!("/api/jobs/{id}"), Some(&token)).await;
status = r.json["status"].as_str().unwrap().into();
if status != "running" {
assert_eq!(status, "success", "{}", r.json["log"]);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
}
assert_eq!(status, "success");
let recs = get(
&app,
&format!("/api/backups/strategies/{sid}/records"),
Some(&token),
)
.await;
let recs = recs.json.as_array().unwrap();
assert_eq!(recs.len(), 1);
assert!(recs[0]["filename"]
.as_str()
.unwrap()
.ends_with(".sql.gz.enc"));
let list = get(&app, "/api/backups/strategies", Some(&token)).await;
assert_eq!(list.json[0]["target_name"], "NAS");
assert_eq!(list.json[0]["last_backup"]["filename"], recs[0]["filename"]);
let upd = send_json(&app, "PUT", &format!("/api/backups/strategies/{sid}"), json!({"name": "Gitea DB", "source": {"type": "postgres_dump", "namespace": "gitea", "pod": "gitea-postgresql-0"},
"schedule": "0 0 3 * * *", "target_id": tid, "retention": 3, "passphrase": "", "enabled": false}), Some(&token)).await;
assert_eq!(upd.status, StatusCode::OK, "{}", upd.json);
assert_eq!(upd.json["enabled"], false);
assert_eq!(
upd.json["encrypted"], true,
"empty passphrase keeps the stored one"
);
assert_eq!(
send_json(
&app,
"DELETE",
&format!("/api/backups/strategies/{sid}"),
json!({}),
Some(&token)
)
.await
.status,
StatusCode::NO_CONTENT
);
assert_eq!(
send_json(
&app,
"DELETE",
&format!("/api/backups/targets/{tid}"),
json!({}),
Some(&token)
)
.await
.status,
StatusCode::NO_CONTENT
);
}
#[tokio::test]
async fn backups_are_admin_only_for_writes() {
let app = test_app_with_admin().await;
let admin = common::login(&app, ADMIN, PW).await.access;
post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&admin)).await;
let user = common::login(&app, "u@x.de", "user-password-123")
.await
.access;
assert_eq!(
get(&app, "/api/backups/targets", Some(&user)).await.status,
StatusCode::OK
);
assert_eq!(
get(&app, "/api/backups/strategies", Some(&user))
.await
.status,
StatusCode::OK
);
assert_eq!(
post(&app, "/api/backups/targets", smb(), Some(&user))
.await
.status,
StatusCode::FORBIDDEN
);
assert_eq!(
get(&app, "/api/backups/targets", None).await.status,
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);
}