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>
230 lines
7.9 KiB
Rust
230 lines
7.9 KiB
Rust
//! 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);
|
||
}
|