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:
@ -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 ----
|
||||
|
||||
@ -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())),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user