WP-30/31/32: backup targets, strategies and execution
SMB (smbclient) and FTP/FTPS (curl with netrc) targets with encrypted credentials and connection test; strategies with cron schedule, retention, optional openssl AES-256 encryption; sources: PVC hostpath tar, pg_dumpall in the Postgres pod, namespace manifests, host directory. Backup job collects, encrypts, uploads, verifies size, records sha256 and applies retention on the target; scheduler starts due strategies. Backups page with target/strategy forms, run now and history. Restore guide in docs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@ -14,3 +14,7 @@ COOKIE_SECURE=false
|
|||||||
# Directory with the built frontend (served as SPA fallback)
|
# Directory with the built frontend (served as SPA fallback)
|
||||||
FRONTEND_DIR=../frontend/dist
|
FRONTEND_DIR=../frontend/dist
|
||||||
RUST_LOG=info,sqlx=warn
|
RUST_LOG=info,sqlx=warn
|
||||||
|
# kubectl invocation used for backups (default: microk8s kubectl if present)
|
||||||
|
#KUBECTL=/snap/bin/microk8s kubectl
|
||||||
|
# scratch directory for backup archives (default: data/work)
|
||||||
|
#WORK_DIR=data/work
|
||||||
|
|||||||
10
ROADMAP.md
10
ROADMAP.md
@ -298,8 +298,8 @@ WP-02 remainder, WP-10, WP-11, WP-12. Verified against the real host: 430 packag
|
|||||||
### Milestone 3 – Vulnerability management (delivered 2026-09-02)
|
### Milestone 3 – Vulnerability management (delivered 2026-09-02)
|
||||||
WP-20, WP-21. Trivy is installed by the deploy script.
|
WP-20, WP-21. Trivy is installed by the deploy script.
|
||||||
|
|
||||||
### Milestone 4 – Backup management (planned)
|
### Milestone 4 – Backup management (delivered 2026-09-02)
|
||||||
WP-30, WP-31, WP-32.
|
WP-30, WP-31, WP-32. Restore procedure in `docs/restore.md`.
|
||||||
|
|
||||||
### Milestone 5 – Hardening and release (planned)
|
### Milestone 5 – Hardening and release (planned)
|
||||||
WP-40, WP-41, WP-42.
|
WP-40, WP-41, WP-42.
|
||||||
@ -360,9 +360,9 @@ The server is reachable via `ssh softvisor` (as root). Findings from the inspect
|
|||||||
| WP-12 | M2 | done | 2026-09-02; overview, restart, scale, set image; upstream tag check not done |
|
| WP-12 | M2 | done | 2026-09-02; overview, restart, scale, set image; upstream tag check not done |
|
||||||
| WP-20 | M3 | done | 2026-09-02; Trivy rootfs + image scans, diff with first/last seen, fixed detection |
|
| WP-20 | M3 | done | 2026-09-02; Trivy rootfs + image scans, diff with first/last seen, fixed detection |
|
||||||
| WP-21 | M3 | done | 2026-09-02; findings UI, acknowledge, mail digest with severity threshold; dashboard widget in WP-40 |
|
| WP-21 | M3 | done | 2026-09-02; findings UI, acknowledge, mail digest with severity threshold; dashboard widget in WP-40 |
|
||||||
| WP-30 | M4 | todo | |
|
| WP-30 | M4 | done | 2026-09-02; SMB via smbclient, FTP/FTPS via curl, credentials encrypted |
|
||||||
| WP-31 | M4 | todo | |
|
| WP-31 | M4 | done | 2026-09-02; sources: PVC hostpath, pg_dumpall, manifests, host dir; openssl encryption |
|
||||||
| WP-32 | M4 | todo | |
|
| WP-32 | M4 | done | 2026-09-02; upload verify, retention, records; docs/restore.md; failure mail not yet |
|
||||||
| WP-40 | M5 | todo | |
|
| WP-40 | M5 | todo | |
|
||||||
| WP-41 | M5 | todo | |
|
| WP-41 | M5 | todo | |
|
||||||
| WP-42 | M5 | todo | |
|
| WP-42 | M5 | todo | |
|
||||||
|
|||||||
1
backend/Cargo.lock
generated
1
backend/Cargo.lock
generated
@ -1100,6 +1100,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
350
backend/crates/api/src/backups.rs
Normal file
350
backend/crates/api/src/backups.rs
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
//! /api/backups: targets, strategies, records, manual runs.
|
||||||
|
use application::backup_service::StrategyStatus;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use domain::backup::{BackupRecord, BackupSource, BackupStrategy, BackupTarget, StorageKind};
|
||||||
|
use domain::jobs::JobKind;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use crate::extract::{AdminUser, AuthUser};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/targets", get(list_targets).post(create_target))
|
||||||
|
.route(
|
||||||
|
"/targets/{id}",
|
||||||
|
get(get_target).put(update_target).delete(delete_target),
|
||||||
|
)
|
||||||
|
.route("/targets/{id}/test", post(test_target))
|
||||||
|
.route("/strategies", get(list_strategies).post(create_strategy))
|
||||||
|
.route(
|
||||||
|
"/strategies/{id}",
|
||||||
|
get(get_strategy)
|
||||||
|
.put(update_strategy)
|
||||||
|
.delete(delete_strategy),
|
||||||
|
)
|
||||||
|
.route("/strategies/{id}/run", post(run_strategy))
|
||||||
|
.route("/strategies/{id}/records", get(records))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- targets ----
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct TargetView {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub kind: StorageKind,
|
||||||
|
pub host: String,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
pub share: String,
|
||||||
|
pub path: String,
|
||||||
|
pub username: String,
|
||||||
|
pub tls: bool,
|
||||||
|
pub password_set: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BackupTarget> for TargetView {
|
||||||
|
fn from(t: BackupTarget) -> Self {
|
||||||
|
Self {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
kind: t.kind,
|
||||||
|
host: t.host,
|
||||||
|
port: t.port,
|
||||||
|
share: t.share,
|
||||||
|
path: t.path,
|
||||||
|
username: t.username,
|
||||||
|
tls: t.tls,
|
||||||
|
password_set: !t.password.is_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct TargetRequest {
|
||||||
|
pub name: String,
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
pub kind: StorageKind,
|
||||||
|
pub host: String,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub share: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: String,
|
||||||
|
/// Empty keeps the stored password on update.
|
||||||
|
#[serde(default)]
|
||||||
|
pub password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tls: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TargetRequest {
|
||||||
|
fn into_target(self, id: Uuid) -> BackupTarget {
|
||||||
|
BackupTarget {
|
||||||
|
id,
|
||||||
|
name: self.name.trim().into(),
|
||||||
|
kind: self.kind,
|
||||||
|
host: self.host.trim().into(),
|
||||||
|
port: self.port,
|
||||||
|
share: self.share.trim().into(),
|
||||||
|
path: self.path.trim().into(),
|
||||||
|
username: self.username,
|
||||||
|
password: self.password,
|
||||||
|
tls: self.tls,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/backups/targets", tag = "backups", security(("bearer" = [])), responses((status = 200, body = Vec<TargetView>)))]
|
||||||
|
async fn list_targets(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
_: AuthUser,
|
||||||
|
) -> Result<Json<Vec<TargetView>>, ApiError> {
|
||||||
|
Ok(Json(
|
||||||
|
s.backups
|
||||||
|
.list_targets()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/backups/targets/{id}", tag = "backups", security(("bearer" = [])), responses((status = 200, body = TargetView), (status = 404)))]
|
||||||
|
async fn get_target(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
_: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<TargetView>, ApiError> {
|
||||||
|
Ok(Json(s.backups.get_target(id).await?.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/backups/targets", tag = "backups", security(("bearer" = [])), request_body = TargetRequest, responses((status = 201, body = TargetView), (status = 422)))]
|
||||||
|
async fn create_target(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
_: AdminUser,
|
||||||
|
Json(req): Json<TargetRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<TargetView>), ApiError> {
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(
|
||||||
|
s.backups
|
||||||
|
.create_target(req.into_target(Uuid::nil()))
|
||||||
|
.await?
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(put, path = "/api/backups/targets/{id}", tag = "backups", security(("bearer" = [])), request_body = TargetRequest, responses((status = 200, body = TargetView), (status = 404), (status = 422)))]
|
||||||
|
async fn update_target(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
_: AdminUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<TargetRequest>,
|
||||||
|
) -> Result<Json<TargetView>, ApiError> {
|
||||||
|
Ok(Json(
|
||||||
|
s.backups.update_target(req.into_target(id)).await?.into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(delete, path = "/api/backups/targets/{id}", tag = "backups", security(("bearer" = [])), responses((status = 204), (status = 404), (status = 409)))]
|
||||||
|
async fn delete_target(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
_: AdminUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
s.backups.delete_target(id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/backups/targets/{id}/test", tag = "backups", security(("bearer" = [])), responses((status = 204), (status = 404), (status = 502)))]
|
||||||
|
async fn test_target(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
_: AdminUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
s.backups.test_target(id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- strategies ----
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct StrategyView {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
#[schema(value_type = Object)]
|
||||||
|
pub source: BackupSource,
|
||||||
|
pub schedule: String,
|
||||||
|
pub target_id: Uuid,
|
||||||
|
pub retention: u32,
|
||||||
|
pub encrypted: bool,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BackupStrategy> for StrategyView {
|
||||||
|
fn from(s: BackupStrategy) -> Self {
|
||||||
|
Self {
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
source: s.source,
|
||||||
|
schedule: s.schedule,
|
||||||
|
target_id: s.target_id,
|
||||||
|
retention: s.retention,
|
||||||
|
encrypted: s.passphrase.is_some(),
|
||||||
|
enabled: s.enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct StrategyStatusView {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub strategy: StrategyView,
|
||||||
|
pub target_name: String,
|
||||||
|
#[schema(value_type = Option<Object>)]
|
||||||
|
pub last_backup: Option<BackupRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StrategyStatus> for StrategyStatusView {
|
||||||
|
fn from(s: StrategyStatus) -> Self {
|
||||||
|
Self {
|
||||||
|
strategy: s.strategy.into(),
|
||||||
|
target_name: s.target_name,
|
||||||
|
last_backup: s.last_backup,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct StrategyRequest {
|
||||||
|
pub name: String,
|
||||||
|
#[schema(value_type = Object)]
|
||||||
|
pub source: BackupSource,
|
||||||
|
pub schedule: String,
|
||||||
|
pub target_id: Uuid,
|
||||||
|
pub retention: u32,
|
||||||
|
/// `null` = no encryption; empty string keeps the stored passphrase on update.
|
||||||
|
#[serde(default)]
|
||||||
|
pub passphrase: Option<String>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StrategyRequest {
|
||||||
|
fn into_strategy(self, id: Uuid) -> BackupStrategy {
|
||||||
|
BackupStrategy {
|
||||||
|
id,
|
||||||
|
name: self.name.trim().into(),
|
||||||
|
source: self.source,
|
||||||
|
schedule: self.schedule.trim().into(),
|
||||||
|
target_id: self.target_id,
|
||||||
|
retention: self.retention,
|
||||||
|
passphrase: self.passphrase,
|
||||||
|
enabled: self.enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/backups/strategies", tag = "backups", security(("bearer" = [])), responses((status = 200, body = Vec<StrategyStatusView>)))]
|
||||||
|
async fn list_strategies(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
_: AuthUser,
|
||||||
|
) -> Result<Json<Vec<StrategyStatusView>>, ApiError> {
|
||||||
|
Ok(Json(
|
||||||
|
s.backups
|
||||||
|
.list_strategies()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/backups/strategies/{id}", tag = "backups", security(("bearer" = [])), responses((status = 200, body = StrategyView), (status = 404)))]
|
||||||
|
async fn get_strategy(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
_: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<StrategyView>, ApiError> {
|
||||||
|
Ok(Json(s.backups.get_strategy(id).await?.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/backups/strategies", tag = "backups", security(("bearer" = [])), request_body = StrategyRequest, responses((status = 201, body = StrategyView), (status = 404), (status = 422)))]
|
||||||
|
async fn create_strategy(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
_: AdminUser,
|
||||||
|
Json(req): Json<StrategyRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<StrategyView>), ApiError> {
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(
|
||||||
|
s.backups
|
||||||
|
.create_strategy(req.into_strategy(Uuid::nil()))
|
||||||
|
.await?
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(put, path = "/api/backups/strategies/{id}", tag = "backups", security(("bearer" = [])), request_body = StrategyRequest, responses((status = 200, body = StrategyView), (status = 404), (status = 422)))]
|
||||||
|
async fn update_strategy(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
_: AdminUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<StrategyRequest>,
|
||||||
|
) -> Result<Json<StrategyView>, ApiError> {
|
||||||
|
Ok(Json(
|
||||||
|
s.backups
|
||||||
|
.update_strategy(req.into_strategy(id))
|
||||||
|
.await?
|
||||||
|
.into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(delete, path = "/api/backups/strategies/{id}", tag = "backups", security(("bearer" = [])), responses((status = 204), (status = 404)))]
|
||||||
|
async fn delete_strategy(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
_: AdminUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
s.backups.delete_strategy(id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/backups/strategies/{id}/run", tag = "backups", security(("bearer" = [])), responses((status = 202, body = crate::jobs::JobRunDto), (status = 404), (status = 409)))]
|
||||||
|
async fn run_strategy(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
AdminUser(admin): AdminUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<(StatusCode, Json<crate::jobs::JobRunDto>), ApiError> {
|
||||||
|
s.backups.get_strategy(id).await?;
|
||||||
|
let run = s
|
||||||
|
.jobs
|
||||||
|
.start(JobKind::Backup, Some(id.to_string()), &admin.email)
|
||||||
|
.await?;
|
||||||
|
Ok((StatusCode::ACCEPTED, Json(run.into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/backups/strategies/{id}/records", tag = "backups", security(("bearer" = [])), responses((status = 200, body = Vec<Object>)))]
|
||||||
|
async fn records(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
_: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<BackupRecord>>, ApiError> {
|
||||||
|
Ok(Json(s.backups.records(id).await?))
|
||||||
|
}
|
||||||
@ -11,6 +11,10 @@ pub struct Config {
|
|||||||
pub fake_host: bool,
|
pub fake_host: bool,
|
||||||
/// Path to a kubeconfig; None infers. Defaults to the microk8s client config if present.
|
/// Path to a kubeconfig; None infers. Defaults to the microk8s client config if present.
|
||||||
pub kubeconfig: Option<String>,
|
pub kubeconfig: Option<String>,
|
||||||
|
/// kubectl invocation for backups, e.g. ["/snap/bin/microk8s", "kubectl"].
|
||||||
|
pub kubectl: Vec<String>,
|
||||||
|
/// Scratch directory for backup archives.
|
||||||
|
pub work_dir: std::path::PathBuf,
|
||||||
pub bind: SocketAddr,
|
pub bind: SocketAddr,
|
||||||
pub bootstrap_admin: Option<(String, String)>,
|
pub bootstrap_admin: Option<(String, String)>,
|
||||||
pub cookie_secure: bool,
|
pub cookie_secure: bool,
|
||||||
@ -34,6 +38,18 @@ impl Config {
|
|||||||
jwt_secret,
|
jwt_secret,
|
||||||
master_key,
|
master_key,
|
||||||
fake_host: env("FAKE_HOST").is_some_and(|v| v == "true" || v == "1"),
|
fake_host: env("FAKE_HOST").is_some_and(|v| v == "true" || v == "1"),
|
||||||
|
kubectl: env("KUBECTL")
|
||||||
|
.map(|v| v.split_whitespace().map(String::from).collect())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
if std::path::Path::new("/snap/bin/microk8s").exists() {
|
||||||
|
vec!["/snap/bin/microk8s".into(), "kubectl".into()]
|
||||||
|
} else {
|
||||||
|
vec!["kubectl".into()]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
work_dir: env("WORK_DIR")
|
||||||
|
.map(Into::into)
|
||||||
|
.unwrap_or_else(|| "data/work".into()),
|
||||||
kubeconfig: env("KUBECONFIG").or_else(|| {
|
kubeconfig: env("KUBECONFIG").or_else(|| {
|
||||||
let microk8s = "/var/snap/microk8s/current/credentials/client.config";
|
let microk8s = "/var/snap/microk8s/current/credentials/client.config";
|
||||||
std::path::Path::new(microk8s)
|
std::path::Path::new(microk8s)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
//! HTTP API layer (axum). `build_app` is used by both the binary and the integration tests.
|
//! HTTP API layer (axum). `build_app` is used by both the binary and the integration tests.
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod backups;
|
||||||
pub mod cluster;
|
pub mod cluster;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@ -17,18 +18,24 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use application::scheduler::Scheduler;
|
use application::scheduler::Scheduler;
|
||||||
use application::{
|
use application::{
|
||||||
AuthService, ClusterService, InventoryService, JobRunner, PackageRefreshJob, PackageUpgradeJob,
|
AuthService, BackupDeps, BackupJob, BackupService, ClusterService, InventoryService, JobRunner,
|
||||||
SettingsService, UserService, VulnerabilityScanJob, VulnerabilityService,
|
PackageRefreshJob, PackageUpgradeJob, SettingsService, UserService, VulnerabilityScanJob,
|
||||||
|
VulnerabilityService,
|
||||||
};
|
};
|
||||||
use axum::{routing::get, Json, Router};
|
use axum::{routing::get, Json, Router};
|
||||||
use domain::jobs::JobKind;
|
use domain::jobs::JobKind;
|
||||||
use domain::ports::Mailer;
|
use domain::ports::Mailer;
|
||||||
use domain::ports::{ClusterGateway, HostInspector, HostUpdater, VulnerabilityScanner};
|
use domain::ports::{
|
||||||
|
BackupCollector, BackupStorage, ClusterGateway, FileEncryptor, HostInspector, HostUpdater,
|
||||||
|
VulnerabilityScanner,
|
||||||
|
};
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
AesGcmCipher, Argon2Hasher, DbPool, DebianInspector, DebianUpdater, FakeClusterGateway,
|
AesGcmCipher, Argon2Hasher, CommandBackupStorage, DbPool, DebianInspector, DebianUpdater,
|
||||||
FakeHostInspector, FakeHostUpdater, FakeScanner, JwtIssuer, KubeGateway, LettreMailer,
|
DirBackupStorage, FakeBackupCollector, FakeClusterGateway, FakeHostInspector, FakeHostUpdater,
|
||||||
SqliteAuditLog, SqliteFindings, SqliteInventory, SqliteJobRuns, SqliteRefreshTokens,
|
FakeScanner, JwtIssuer, KubeBackupCollector, KubeGateway, LettreMailer, OpensslEncryptor,
|
||||||
SqliteSettings, SqliteUsers, SystemCommandRunner, TrivyScanner,
|
SqliteAuditLog, SqliteBackupRecords, SqliteBackupStrategies, SqliteBackupTargets,
|
||||||
|
SqliteFindings, SqliteInventory, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings,
|
||||||
|
SqliteUsers, SystemCommandRunner, TrivyScanner,
|
||||||
};
|
};
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
@ -42,6 +49,9 @@ pub struct Adapters {
|
|||||||
pub updater: Arc<dyn HostUpdater>,
|
pub updater: Arc<dyn HostUpdater>,
|
||||||
pub cluster: Arc<dyn ClusterGateway>,
|
pub cluster: Arc<dyn ClusterGateway>,
|
||||||
pub scanner: Arc<dyn VulnerabilityScanner>,
|
pub scanner: Arc<dyn VulnerabilityScanner>,
|
||||||
|
pub storage: Arc<dyn BackupStorage>,
|
||||||
|
pub collector: Arc<dyn BackupCollector>,
|
||||||
|
pub encryptor: Arc<dyn FileEncryptor>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -54,6 +64,7 @@ pub struct AppState {
|
|||||||
pub inventory: Arc<InventoryService>,
|
pub inventory: Arc<InventoryService>,
|
||||||
pub cluster: Arc<ClusterService>,
|
pub cluster: Arc<ClusterService>,
|
||||||
pub vulns: Arc<VulnerabilityService>,
|
pub vulns: Arc<VulnerabilityService>,
|
||||||
|
pub backups: Arc<BackupService>,
|
||||||
pub login_limiter: Arc<rate_limit::RateLimiter>,
|
pub login_limiter: Arc<rate_limit::RateLimiter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +79,9 @@ impl AppState {
|
|||||||
updater: Arc::new(FakeHostUpdater),
|
updater: Arc::new(FakeHostUpdater),
|
||||||
cluster: Arc::new(FakeClusterGateway),
|
cluster: Arc::new(FakeClusterGateway),
|
||||||
scanner: Arc::new(FakeScanner),
|
scanner: Arc::new(FakeScanner),
|
||||||
|
storage: Arc::new(DirBackupStorage::new(cfg.work_dir.join("fake-remote"))),
|
||||||
|
collector: Arc::new(FakeBackupCollector),
|
||||||
|
encryptor: Arc::new(OpensslEncryptor::new(runner.clone())),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Adapters {
|
Adapters {
|
||||||
@ -75,7 +89,13 @@ impl AppState {
|
|||||||
inspector: Arc::new(DebianInspector::new(runner.clone())),
|
inspector: Arc::new(DebianInspector::new(runner.clone())),
|
||||||
updater: Arc::new(DebianUpdater::new(runner.clone())),
|
updater: Arc::new(DebianUpdater::new(runner.clone())),
|
||||||
cluster: Arc::new(KubeGateway::new(cfg.kubeconfig.clone())),
|
cluster: Arc::new(KubeGateway::new(cfg.kubeconfig.clone())),
|
||||||
scanner: Arc::new(TrivyScanner::new(runner)),
|
scanner: Arc::new(TrivyScanner::new(runner.clone())),
|
||||||
|
storage: Arc::new(CommandBackupStorage::new(runner.clone())),
|
||||||
|
collector: Arc::new(KubeBackupCollector::new(
|
||||||
|
runner.clone(),
|
||||||
|
cfg.kubectl.clone(),
|
||||||
|
)),
|
||||||
|
encryptor: Arc::new(OpensslEncryptor::new(runner)),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Self::with_adapters(cfg, pool, adapters, |r| r)
|
Self::with_adapters(cfg, pool, adapters, |r| r)
|
||||||
@ -94,6 +114,9 @@ impl AppState {
|
|||||||
updater,
|
updater,
|
||||||
cluster,
|
cluster,
|
||||||
scanner,
|
scanner,
|
||||||
|
storage,
|
||||||
|
collector,
|
||||||
|
encryptor,
|
||||||
} = adapters;
|
} = adapters;
|
||||||
let pool_for_findings = pool.clone();
|
let pool_for_findings = pool.clone();
|
||||||
let cluster_gateway = cluster.clone();
|
let cluster_gateway = cluster.clone();
|
||||||
@ -107,6 +130,16 @@ impl AppState {
|
|||||||
Arc::new(JwtIssuer::new(&cfg.jwt_secret)),
|
Arc::new(JwtIssuer::new(&cfg.jwt_secret)),
|
||||||
);
|
);
|
||||||
let cipher = Arc::new(AesGcmCipher::from_hex(&cfg.master_key)?);
|
let cipher = Arc::new(AesGcmCipher::from_hex(&cfg.master_key)?);
|
||||||
|
let backups = Arc::new(BackupService::new(BackupDeps {
|
||||||
|
targets: Arc::new(SqliteBackupTargets(pool.clone())),
|
||||||
|
strategies: Arc::new(SqliteBackupStrategies(pool.clone())),
|
||||||
|
records: Arc::new(SqliteBackupRecords(pool.clone())),
|
||||||
|
storage,
|
||||||
|
collector,
|
||||||
|
encryptor,
|
||||||
|
cipher: cipher.clone(),
|
||||||
|
work_dir: cfg.work_dir.clone(),
|
||||||
|
}));
|
||||||
let settings = Arc::new(SettingsService::new(
|
let settings = Arc::new(SettingsService::new(
|
||||||
Arc::new(SqliteSettings(pool.clone())),
|
Arc::new(SqliteSettings(pool.clone())),
|
||||||
cipher,
|
cipher,
|
||||||
@ -135,10 +168,14 @@ impl AppState {
|
|||||||
cluster_gateway,
|
cluster_gateway,
|
||||||
settings.clone(),
|
settings.clone(),
|
||||||
));
|
));
|
||||||
let jobs = Arc::new(register_jobs(runner.register(
|
let jobs = Arc::new(register_jobs(
|
||||||
JobKind::VulnerabilityScan,
|
runner
|
||||||
Arc::new(VulnerabilityScanJob(vulns.clone())),
|
.register(
|
||||||
)));
|
JobKind::VulnerabilityScan,
|
||||||
|
Arc::new(VulnerabilityScanJob(vulns.clone())),
|
||||||
|
)
|
||||||
|
.register(JobKind::Backup, Arc::new(BackupJob(backups.clone()))),
|
||||||
|
));
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
cfg,
|
cfg,
|
||||||
auth: Arc::new(auth),
|
auth: Arc::new(auth),
|
||||||
@ -148,6 +185,7 @@ impl AppState {
|
|||||||
inventory,
|
inventory,
|
||||||
cluster,
|
cluster,
|
||||||
vulns,
|
vulns,
|
||||||
|
backups,
|
||||||
login_limiter: Arc::new(rate_limit::RateLimiter::new(
|
login_limiter: Arc::new(rate_limit::RateLimiter::new(
|
||||||
10,
|
10,
|
||||||
std::time::Duration::from_secs(60),
|
std::time::Duration::from_secs(60),
|
||||||
@ -157,7 +195,11 @@ impl AppState {
|
|||||||
|
|
||||||
/// Spawn the cron scheduler on the current runtime.
|
/// Spawn the cron scheduler on the current runtime.
|
||||||
pub fn start_scheduler(&self) {
|
pub fn start_scheduler(&self) {
|
||||||
tokio::spawn(Scheduler::new(self.jobs.clone(), self.settings.clone()).run());
|
tokio::spawn(
|
||||||
|
Scheduler::new(self.jobs.clone(), self.settings.clone())
|
||||||
|
.with_backups(self.backups.clone())
|
||||||
|
.run(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn bootstrap(&self) -> anyhow::Result<()> {
|
pub async fn bootstrap(&self) -> anyhow::Result<()> {
|
||||||
@ -187,6 +229,7 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
.nest("/api/system", system::router())
|
.nest("/api/system", system::router())
|
||||||
.nest("/api/cluster", cluster::router())
|
.nest("/api/cluster", cluster::router())
|
||||||
.nest("/api/vulnerabilities", vulnerabilities::router())
|
.nest("/api/vulnerabilities", vulnerabilities::router())
|
||||||
|
.nest("/api/backups", backups::router())
|
||||||
.fallback_service(spa)
|
.fallback_service(spa)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
|
|||||||
@ -16,6 +16,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
{
|
{
|
||||||
std::fs::create_dir_all(dir)?;
|
std::fs::create_dir_all(dir)?;
|
||||||
}
|
}
|
||||||
|
std::fs::create_dir_all(&cfg.work_dir)?;
|
||||||
let pool = infrastructure::connect(&cfg.database_url).await?;
|
let pool = infrastructure::connect(&cfg.database_url).await?;
|
||||||
let state = AppState::new(cfg.clone(), pool)?;
|
let state = AppState::new(cfg.clone(), pool)?;
|
||||||
state.bootstrap().await?;
|
state.bootstrap().await?;
|
||||||
|
|||||||
@ -30,6 +30,10 @@ impl Modify for BearerAuth {
|
|||||||
crate::cluster::overview, crate::cluster::restart, crate::cluster::scale, crate::cluster::set_image,
|
crate::cluster::overview, crate::cluster::restart, crate::cluster::scale, crate::cluster::set_image,
|
||||||
crate::vulnerabilities::list, crate::vulnerabilities::summary, crate::vulnerabilities::targets, crate::vulnerabilities::set_status,
|
crate::vulnerabilities::list, crate::vulnerabilities::summary, crate::vulnerabilities::targets, crate::vulnerabilities::set_status,
|
||||||
crate::settings::get_notifications, crate::settings::put_notifications,
|
crate::settings::get_notifications, crate::settings::put_notifications,
|
||||||
|
crate::backups::list_targets, crate::backups::get_target, crate::backups::create_target, crate::backups::update_target,
|
||||||
|
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,
|
||||||
),
|
),
|
||||||
modifiers(&BearerAuth)
|
modifiers(&BearerAuth)
|
||||||
)]
|
)]
|
||||||
|
|||||||
@ -17,6 +17,8 @@ pub fn test_config() -> Config {
|
|||||||
master_key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".into(),
|
master_key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".into(),
|
||||||
fake_host: true,
|
fake_host: true,
|
||||||
kubeconfig: None,
|
kubeconfig: None,
|
||||||
|
kubectl: vec!["kubectl".into()],
|
||||||
|
work_dir: std::env::temp_dir().join(format!("monitoring-test-{}", uuid::Uuid::new_v4())),
|
||||||
bind: "127.0.0.1:0".parse().unwrap(),
|
bind: "127.0.0.1:0".parse().unwrap(),
|
||||||
bootstrap_admin: None,
|
bootstrap_admin: None,
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
@ -86,6 +88,13 @@ async fn build_test_app_with(cfg: Config) -> Router {
|
|||||||
updater: Arc::new(infrastructure::FakeHostUpdater),
|
updater: Arc::new(infrastructure::FakeHostUpdater),
|
||||||
cluster: Arc::new(infrastructure::FakeClusterGateway),
|
cluster: Arc::new(infrastructure::FakeClusterGateway),
|
||||||
scanner: Arc::new(infrastructure::FakeScanner),
|
scanner: Arc::new(infrastructure::FakeScanner),
|
||||||
|
storage: Arc::new(infrastructure::DirBackupStorage::new(
|
||||||
|
cfg.work_dir.join("remote"),
|
||||||
|
)),
|
||||||
|
collector: Arc::new(infrastructure::FakeBackupCollector),
|
||||||
|
encryptor: Arc::new(infrastructure::OpensslEncryptor::new(Arc::new(
|
||||||
|
infrastructure::SystemCommandRunner,
|
||||||
|
))),
|
||||||
};
|
};
|
||||||
let state = AppState::with_adapters(cfg, pool, adapters, register_test_jobs).expect("state");
|
let state = AppState::with_adapters(cfg, pool, adapters, register_test_jobs).expect("state");
|
||||||
state.bootstrap().await.expect("bootstrap");
|
state.bootstrap().await.expect("bootstrap");
|
||||||
|
|||||||
@ -58,17 +58,25 @@ async fn unknown_kind_is_404_and_run_is_admin_only() {
|
|||||||
.status,
|
.status,
|
||||||
StatusCode::UNPROCESSABLE_ENTITY
|
StatusCode::UNPROCESSABLE_ENTITY
|
||||||
);
|
);
|
||||||
assert_eq!(
|
// every kind has a handler now; a backup without a strategy id starts and then fails
|
||||||
post(
|
let run = post(
|
||||||
&app,
|
&app,
|
||||||
"/api/jobs/run",
|
"/api/jobs/run",
|
||||||
json!({"kind": "backup"}),
|
json!({"kind": "backup"}),
|
||||||
Some(&admin)
|
Some(&admin),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.status,
|
assert_eq!(run.status, StatusCode::ACCEPTED);
|
||||||
StatusCode::NOT_FOUND
|
let id = run.json["id"].as_str().unwrap().to_string();
|
||||||
);
|
for _ in 0..50 {
|
||||||
|
let r = get(&app, &format!("/api/jobs/{id}"), Some(&admin)).await;
|
||||||
|
if r.json["status"] != "running" {
|
||||||
|
assert_eq!(r.json["status"], "failed");
|
||||||
|
assert!(r.json["log"].as_str().unwrap().contains("strategy id"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&admin)).await;
|
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")
|
let user = common::login(&app, "u@x.de", "user-password-123")
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -10,9 +10,11 @@ use domain::ports::{
|
|||||||
BackupTargetRepository, Cipher, FileEncryptor,
|
BackupTargetRepository, Cipher, FileEncryptor,
|
||||||
};
|
};
|
||||||
use domain::DomainError;
|
use domain::DomainError;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::jobs::{JobHandler, JobLog};
|
use crate::jobs::{JobHandler, JobLog};
|
||||||
|
use crate::scheduler::{is_due, validate_cron};
|
||||||
|
|
||||||
pub struct BackupDeps {
|
pub struct BackupDeps {
|
||||||
pub targets: Arc<dyn BackupTargetRepository>,
|
pub targets: Arc<dyn BackupTargetRepository>,
|
||||||
@ -42,65 +44,316 @@ impl BackupService {
|
|||||||
Self { d: deps }
|
Self { d: deps }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decrypt_target(&self, mut t: BackupTarget) -> Result<BackupTarget, DomainError> {
|
||||||
|
if !t.password.is_empty() {
|
||||||
|
t.password = self.d.cipher.decrypt(&t.password)?;
|
||||||
|
}
|
||||||
|
Ok(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encrypt_target(&self, mut t: BackupTarget) -> Result<BackupTarget, DomainError> {
|
||||||
|
if !t.password.is_empty() {
|
||||||
|
t.password = self.d.cipher.encrypt(&t.password)?;
|
||||||
|
}
|
||||||
|
Ok(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt_strategy(&self, mut s: BackupStrategy) -> Result<BackupStrategy, DomainError> {
|
||||||
|
if let Some(p) = &s.passphrase {
|
||||||
|
s.passphrase = Some(self.d.cipher.decrypt(p)?);
|
||||||
|
}
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encrypt_strategy(&self, mut s: BackupStrategy) -> Result<BackupStrategy, DomainError> {
|
||||||
|
if let Some(p) = &s.passphrase {
|
||||||
|
s.passphrase = Some(self.d.cipher.encrypt(p)?);
|
||||||
|
}
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- targets ----
|
// ---- targets ----
|
||||||
pub async fn list_targets(&self) -> Result<Vec<BackupTarget>, DomainError> {
|
pub async fn list_targets(&self) -> Result<Vec<BackupTarget>, DomainError> {
|
||||||
todo!()
|
self.d
|
||||||
|
.targets
|
||||||
|
.list()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| self.decrypt_target(t))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
pub async fn get_target(&self, _id: Uuid) -> Result<BackupTarget, DomainError> {
|
|
||||||
todo!()
|
pub async fn get_target(&self, id: Uuid) -> Result<BackupTarget, DomainError> {
|
||||||
|
self.decrypt_target(self.d.targets.get(id).await?.ok_or(DomainError::NotFound)?)
|
||||||
}
|
}
|
||||||
pub async fn create_target(&self, _t: BackupTarget) -> Result<BackupTarget, DomainError> {
|
|
||||||
todo!()
|
pub async fn create_target(&self, mut t: BackupTarget) -> Result<BackupTarget, DomainError> {
|
||||||
|
t.validate()?;
|
||||||
|
t.id = Uuid::new_v4();
|
||||||
|
self.d
|
||||||
|
.targets
|
||||||
|
.insert(&self.encrypt_target(t.clone())?)
|
||||||
|
.await?;
|
||||||
|
Ok(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Empty password keeps the stored one.
|
/// Empty password keeps the stored one.
|
||||||
pub async fn update_target(&self, _t: BackupTarget) -> Result<BackupTarget, DomainError> {
|
pub async fn update_target(&self, mut t: BackupTarget) -> Result<BackupTarget, DomainError> {
|
||||||
todo!()
|
t.validate()?;
|
||||||
|
let current = self.get_target(t.id).await?;
|
||||||
|
if t.password.is_empty() {
|
||||||
|
t.password = current.password;
|
||||||
|
}
|
||||||
|
self.d
|
||||||
|
.targets
|
||||||
|
.update(&self.encrypt_target(t.clone())?)
|
||||||
|
.await?;
|
||||||
|
Ok(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fails with `Conflict` while a strategy still uses the target.
|
/// Fails with `Conflict` while a strategy still uses the target.
|
||||||
pub async fn delete_target(&self, _id: Uuid) -> Result<(), DomainError> {
|
pub async fn delete_target(&self, id: Uuid) -> Result<(), DomainError> {
|
||||||
todo!()
|
if let Some(s) = self
|
||||||
|
.d
|
||||||
|
.strategies
|
||||||
|
.list()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| s.target_id == id)
|
||||||
|
{
|
||||||
|
return Err(DomainError::Conflict(format!(
|
||||||
|
"target is used by strategy '{}'",
|
||||||
|
s.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.d.targets.delete(id).await
|
||||||
}
|
}
|
||||||
pub async fn test_target(&self, _id: Uuid) -> Result<(), DomainError> {
|
|
||||||
todo!()
|
pub async fn test_target(&self, id: Uuid) -> Result<(), DomainError> {
|
||||||
|
let t = self.get_target(id).await?;
|
||||||
|
self.d.storage.test(&t).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- strategies ----
|
// ---- strategies ----
|
||||||
pub async fn list_strategies(&self) -> Result<Vec<StrategyStatus>, DomainError> {
|
pub async fn list_strategies(&self) -> Result<Vec<StrategyStatus>, DomainError> {
|
||||||
todo!()
|
let targets = self.d.targets.list().await?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for s in self.d.strategies.list().await? {
|
||||||
|
let target_name = targets
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.id == s.target_id)
|
||||||
|
.map(|t| t.name.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let last_backup = self.d.records.list_for(s.id, 1).await?.into_iter().next();
|
||||||
|
let mut strategy = self.decrypt_strategy(s)?;
|
||||||
|
// never expose the passphrase in listings
|
||||||
|
strategy.passphrase = strategy.passphrase.map(|_| String::new());
|
||||||
|
out.push(StrategyStatus {
|
||||||
|
strategy,
|
||||||
|
target_name,
|
||||||
|
last_backup,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
}
|
}
|
||||||
pub async fn get_strategy(&self, _id: Uuid) -> Result<BackupStrategy, DomainError> {
|
|
||||||
todo!()
|
pub async fn get_strategy(&self, id: Uuid) -> Result<BackupStrategy, DomainError> {
|
||||||
|
self.decrypt_strategy(
|
||||||
|
self.d
|
||||||
|
.strategies
|
||||||
|
.get(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::NotFound)?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
pub async fn create_strategy(&self, _s: BackupStrategy) -> Result<BackupStrategy, DomainError> {
|
|
||||||
todo!()
|
async fn validate_strategy(&self, s: &BackupStrategy) -> Result<(), DomainError> {
|
||||||
|
s.validate()?;
|
||||||
|
validate_cron(&s.schedule)?;
|
||||||
|
self.d
|
||||||
|
.targets
|
||||||
|
.get(s.target_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::NotFound)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_strategy(
|
||||||
|
&self,
|
||||||
|
mut s: BackupStrategy,
|
||||||
|
) -> Result<BackupStrategy, DomainError> {
|
||||||
|
s.passphrase = s.passphrase.filter(|p| !p.is_empty());
|
||||||
|
self.validate_strategy(&s).await?;
|
||||||
|
s.id = Uuid::new_v4();
|
||||||
|
self.d
|
||||||
|
.strategies
|
||||||
|
.insert(&self.encrypt_strategy(s.clone())?)
|
||||||
|
.await?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
/// Empty passphrase keeps the stored one; `None` removes it.
|
/// Empty passphrase keeps the stored one; `None` removes it.
|
||||||
pub async fn update_strategy(&self, _s: BackupStrategy) -> Result<BackupStrategy, DomainError> {
|
pub async fn update_strategy(
|
||||||
todo!()
|
&self,
|
||||||
|
mut s: BackupStrategy,
|
||||||
|
) -> Result<BackupStrategy, DomainError> {
|
||||||
|
let current = self.get_strategy(s.id).await?;
|
||||||
|
if s.passphrase.as_deref() == Some("") {
|
||||||
|
s.passphrase = current.passphrase;
|
||||||
|
}
|
||||||
|
self.validate_strategy(&s).await?;
|
||||||
|
self.d
|
||||||
|
.strategies
|
||||||
|
.update(&self.encrypt_strategy(s.clone())?)
|
||||||
|
.await?;
|
||||||
|
Ok(s)
|
||||||
}
|
}
|
||||||
pub async fn delete_strategy(&self, _id: Uuid) -> Result<(), DomainError> {
|
|
||||||
todo!()
|
pub async fn delete_strategy(&self, id: Uuid) -> Result<(), DomainError> {
|
||||||
|
self.d.strategies.delete(id).await
|
||||||
}
|
}
|
||||||
pub async fn records(&self, _strategy_id: Uuid) -> Result<Vec<BackupRecord>, DomainError> {
|
|
||||||
todo!()
|
pub async fn records(&self, strategy_id: Uuid) -> Result<Vec<BackupRecord>, DomainError> {
|
||||||
|
self.d.records.list_for(strategy_id, 100).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enabled strategies whose cron fired since their last backup.
|
/// Enabled strategies whose cron fired since their last backup.
|
||||||
pub async fn due_strategies(
|
pub async fn due_strategies(
|
||||||
&self,
|
&self,
|
||||||
_now: DateTime<Utc>,
|
now: DateTime<Utc>,
|
||||||
_grace_secs: i64,
|
grace_secs: i64,
|
||||||
) -> Result<Vec<Uuid>, DomainError> {
|
) -> Result<Vec<Uuid>, DomainError> {
|
||||||
todo!()
|
let mut due = Vec::new();
|
||||||
|
for s in self
|
||||||
|
.d
|
||||||
|
.strategies
|
||||||
|
.list()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.enabled)
|
||||||
|
{
|
||||||
|
let last = self
|
||||||
|
.d
|
||||||
|
.records
|
||||||
|
.list_for(s.id, 1)
|
||||||
|
.await?
|
||||||
|
.first()
|
||||||
|
.map(|r| r.created_at);
|
||||||
|
if is_due(&s.schedule, last, now, grace_secs) {
|
||||||
|
due.push(s.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(due)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collect, encrypt, upload, verify, record, apply retention.
|
/// Collect, encrypt, upload, verify, record, apply retention.
|
||||||
pub async fn run_strategy(
|
pub async fn run_strategy(
|
||||||
&self,
|
&self,
|
||||||
_id: Uuid,
|
id: Uuid,
|
||||||
_log: &dyn JobLog,
|
log: &dyn JobLog,
|
||||||
) -> Result<BackupRecord, DomainError> {
|
) -> Result<BackupRecord, DomainError> {
|
||||||
todo!()
|
let strategy = self.get_strategy(id).await?;
|
||||||
|
let target = self.get_target(strategy.target_id).await?;
|
||||||
|
let work = self.d.work_dir.join(format!("run-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&work)
|
||||||
|
.map_err(|e| DomainError::Storage(format!("work dir: {e}")))?;
|
||||||
|
let result = self.run_in(&strategy, &target, &work, log).await;
|
||||||
|
let _ = std::fs::remove_dir_all(&work);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_in(
|
||||||
|
&self,
|
||||||
|
strategy: &BackupStrategy,
|
||||||
|
target: &BackupTarget,
|
||||||
|
work: &std::path::Path,
|
||||||
|
log: &dyn JobLog,
|
||||||
|
) -> Result<BackupRecord, DomainError> {
|
||||||
|
let started = Utc::now();
|
||||||
|
log.line(&format!(
|
||||||
|
"strategy '{}' -> target '{}' ({})",
|
||||||
|
strategy.name,
|
||||||
|
target.name,
|
||||||
|
target.kind.as_str()
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||||
|
let sink = crate::upgrade_service::channel_sink(tx);
|
||||||
|
let collect = async {
|
||||||
|
let r = self
|
||||||
|
.d
|
||||||
|
.collector
|
||||||
|
.collect(&strategy.source, work, &sink)
|
||||||
|
.await;
|
||||||
|
drop(sink);
|
||||||
|
r
|
||||||
|
};
|
||||||
|
let drain = async {
|
||||||
|
while let Some(l) = rx.recv().await {
|
||||||
|
log.line(&l).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (archive, _) = tokio::join!(collect, drain);
|
||||||
|
let mut file = archive?;
|
||||||
|
|
||||||
|
if let Some(pass) = &strategy.passphrase {
|
||||||
|
log.line("encrypting archive").await;
|
||||||
|
file = self.d.encryptor.encrypt(&file, pass).await?;
|
||||||
|
}
|
||||||
|
let bytes =
|
||||||
|
std::fs::read(&file).map_err(|e| DomainError::Storage(format!("read archive: {e}")))?;
|
||||||
|
let size_bytes = bytes.len() as u64;
|
||||||
|
let sha256 = hex::encode(Sha256::digest(&bytes));
|
||||||
|
drop(bytes);
|
||||||
|
let filename = strategy.filename(started);
|
||||||
|
|
||||||
|
log.line(&format!("uploading {filename} ({size_bytes} bytes)"))
|
||||||
|
.await;
|
||||||
|
self.d.storage.upload(target, &file, &filename).await?;
|
||||||
|
let remote = self.d.storage.list(target).await?;
|
||||||
|
match remote.iter().find(|f| f.name == filename) {
|
||||||
|
Some(f) if f.size_bytes == size_bytes => {
|
||||||
|
log.line(&format!("uploaded and verified {filename}")).await
|
||||||
|
}
|
||||||
|
Some(f) => {
|
||||||
|
return Err(DomainError::Unavailable(format!(
|
||||||
|
"size mismatch after upload: {} != {size_bytes}",
|
||||||
|
f.size_bytes
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(DomainError::Unavailable(
|
||||||
|
"file not found on target after upload".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let record = BackupRecord {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
strategy_id: strategy.id,
|
||||||
|
filename: filename.clone(),
|
||||||
|
size_bytes,
|
||||||
|
sha256,
|
||||||
|
created_at: started,
|
||||||
|
};
|
||||||
|
self.d.records.insert(&record).await?;
|
||||||
|
|
||||||
|
// retention: keep the newest N files of this strategy on the target
|
||||||
|
let prefix = format!("{}_", strategy.slug());
|
||||||
|
let mut mine: Vec<String> = remote
|
||||||
|
.iter()
|
||||||
|
.map(|f| f.name.clone())
|
||||||
|
.filter(|n| n.starts_with(&prefix))
|
||||||
|
.collect();
|
||||||
|
mine.sort();
|
||||||
|
mine.reverse();
|
||||||
|
for old in mine.iter().skip(strategy.retention as usize) {
|
||||||
|
log.line(&format!("retention: deleting {old}")).await;
|
||||||
|
self.d.storage.delete(target, old).await?;
|
||||||
|
self.d.records.delete_by_filename(strategy.id, old).await?;
|
||||||
|
}
|
||||||
|
Ok(record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,7 +362,22 @@ pub struct BackupJob(pub Arc<BackupService>);
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl JobHandler for BackupJob {
|
impl JobHandler for BackupJob {
|
||||||
async fn run(&self, _params: Option<String>, _log: &dyn JobLog) -> Result<(), String> {
|
async fn run(&self, params: Option<String>, log: &dyn JobLog) -> Result<(), String> {
|
||||||
todo!()
|
let id: Uuid = params
|
||||||
|
.as_deref()
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.ok_or("backup job needs a strategy id as params")?
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("invalid strategy id: {e}"))?;
|
||||||
|
let record = self.0.run_strategy(id, log).await.map_err(|e| match e {
|
||||||
|
DomainError::NotFound => "strategy not found".to_string(),
|
||||||
|
e => e.to_string(),
|
||||||
|
})?;
|
||||||
|
log.line(&format!(
|
||||||
|
"backup complete: {} ({} bytes, sha256 {})",
|
||||||
|
record.filename, record.size_bytes, record.sha256
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ use cron::Schedule;
|
|||||||
use domain::jobs::JobKind;
|
use domain::jobs::JobKind;
|
||||||
use domain::DomainError;
|
use domain::DomainError;
|
||||||
|
|
||||||
use crate::{JobRunner, SettingsService};
|
use crate::{BackupService, JobRunner, SettingsService};
|
||||||
|
|
||||||
/// Validate a 6-field cron expression (seconds first).
|
/// Validate a 6-field cron expression (seconds first).
|
||||||
pub fn validate_cron(expr: &str) -> Result<(), DomainError> {
|
pub fn validate_cron(expr: &str) -> Result<(), DomainError> {
|
||||||
@ -38,6 +38,7 @@ pub fn is_due(
|
|||||||
pub struct Scheduler {
|
pub struct Scheduler {
|
||||||
runner: Arc<JobRunner>,
|
runner: Arc<JobRunner>,
|
||||||
settings: Arc<SettingsService>,
|
settings: Arc<SettingsService>,
|
||||||
|
backups: Option<Arc<BackupService>>,
|
||||||
tick: Duration,
|
tick: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,10 +47,16 @@ impl Scheduler {
|
|||||||
Self {
|
Self {
|
||||||
runner,
|
runner,
|
||||||
settings,
|
settings,
|
||||||
|
backups: None,
|
||||||
tick: Duration::from_secs(30),
|
tick: Duration::from_secs(30),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_backups(mut self, backups: Arc<BackupService>) -> Self {
|
||||||
|
self.backups = Some(backups);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// One pass: start every registered kind that is due. Returns the kinds started.
|
/// One pass: start every registered kind that is due. Returns the kinds started.
|
||||||
pub async fn tick_once(&self, now: DateTime<Utc>) -> Vec<JobKind> {
|
pub async fn tick_once(&self, now: DateTime<Utc>) -> Vec<JobKind> {
|
||||||
let mut started = Vec::new();
|
let mut started = Vec::new();
|
||||||
@ -70,6 +77,27 @@ impl Scheduler {
|
|||||||
started.push(kind);
|
started.push(kind);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(backups) = &self.backups {
|
||||||
|
match backups
|
||||||
|
.due_strategies(now, self.tick.as_secs() as i64 * 2)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(ids) => {
|
||||||
|
for id in ids {
|
||||||
|
// one backup at a time; the rest is picked up on a later tick
|
||||||
|
if self
|
||||||
|
.runner
|
||||||
|
.start(JobKind::Backup, Some(id.to_string()), "scheduler")
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
started.push(JobKind::Backup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("scheduler: cannot read backup strategies: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
started
|
started
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -748,7 +748,7 @@ impl BackupRecordRepository for MemRecords {
|
|||||||
.filter(|r| r.strategy_id == strategy_id)
|
.filter(|r| r.strategy_id == strategy_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
v.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
v.sort_by_key(|r| std::cmp::Reverse(r.created_at));
|
||||||
v.truncate(limit as usize);
|
v.truncate(limit as usize);
|
||||||
Ok(v)
|
Ok(v)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,7 +46,11 @@ pub struct PackageUpgradeJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Bridges the synchronous `LineSink` of the updater to the async job log.
|
/// Bridges the synchronous `LineSink` of the updater to the async job log.
|
||||||
struct ChannelSink(tokio::sync::mpsc::UnboundedSender<String>);
|
pub struct ChannelSink(tokio::sync::mpsc::UnboundedSender<String>);
|
||||||
|
|
||||||
|
pub fn channel_sink(tx: tokio::sync::mpsc::UnboundedSender<String>) -> ChannelSink {
|
||||||
|
ChannelSink(tx)
|
||||||
|
}
|
||||||
|
|
||||||
impl LineSink for ChannelSink {
|
impl LineSink for ChannelSink {
|
||||||
fn line(&self, text: &str) {
|
fn line(&self, text: &str) {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ kube = { version = "0.99", default-features = false, features = ["client", "rust
|
|||||||
k8s-openapi = { version = "0.24", features = ["v1_32"] }
|
k8s-openapi = { version = "0.24", features = ["v1_32"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
33
backend/crates/infrastructure/migrations/0005_backups.sql
Normal file
33
backend/crates/infrastructure/migrations/0005_backups.sql
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
CREATE TABLE backup_targets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('smb', 'ftp')),
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER,
|
||||||
|
share TEXT NOT NULL DEFAULT '',
|
||||||
|
path TEXT NOT NULL DEFAULT '',
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
|
password TEXT NOT NULL DEFAULT '',
|
||||||
|
tls INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE backup_strategies (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
schedule TEXT NOT NULL,
|
||||||
|
target_id TEXT NOT NULL REFERENCES backup_targets(id),
|
||||||
|
retention INTEGER NOT NULL,
|
||||||
|
passphrase TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE backup_records (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
strategy_id TEXT NOT NULL REFERENCES backup_strategies(id) ON DELETE CASCADE,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX backup_records_strategy ON backup_records(strategy_id, created_at DESC);
|
||||||
735
backend/crates/infrastructure/src/backup.rs
Normal file
735
backend/crates/infrastructure/src/backup.rs
Normal file
@ -0,0 +1,735 @@
|
|||||||
|
//! Backup adapters: remote storage via smbclient/curl, collectors via kubectl/tar, openssl encryption,
|
||||||
|
//! and file-based fakes for development.
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::backup::{BackupSource, BackupTarget, RemoteFile, StorageKind};
|
||||||
|
use domain::ports::{BackupCollector, BackupStorage, FileEncryptor, LineSink};
|
||||||
|
use domain::DomainError;
|
||||||
|
|
||||||
|
use crate::host::CommandRunner;
|
||||||
|
|
||||||
|
fn unavailable(what: &str, out: &crate::host::Output) -> DomainError {
|
||||||
|
let tail: Vec<&str> = out
|
||||||
|
.stderr
|
||||||
|
.lines()
|
||||||
|
.chain(out.stdout.lines())
|
||||||
|
.rev()
|
||||||
|
.take(3)
|
||||||
|
.collect();
|
||||||
|
DomainError::Unavailable(format!(
|
||||||
|
"{what}: {}",
|
||||||
|
tail.into_iter().rev().collect::<Vec<_>>().join(" | ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_path(dir: &str, name: &str) -> String {
|
||||||
|
let dir = dir.trim_matches('/');
|
||||||
|
if dir.is_empty() {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{dir}/{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- storage
|
||||||
|
|
||||||
|
pub struct CommandBackupStorage {
|
||||||
|
runner: Arc<dyn CommandRunner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandBackupStorage {
|
||||||
|
pub fn new(runner: Arc<dyn CommandRunner>) -> Self {
|
||||||
|
Self { runner }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn smb(
|
||||||
|
&self,
|
||||||
|
t: &BackupTarget,
|
||||||
|
command: &str,
|
||||||
|
) -> Result<crate::host::Output, DomainError> {
|
||||||
|
let service = format!("//{}/{}", t.host, t.share);
|
||||||
|
let mut args = vec![
|
||||||
|
service.as_str(),
|
||||||
|
"-U",
|
||||||
|
if t.username.is_empty() {
|
||||||
|
"guest"
|
||||||
|
} else {
|
||||||
|
t.username.as_str()
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let port;
|
||||||
|
if let Some(p) = t.port {
|
||||||
|
port = p.to_string();
|
||||||
|
args.extend(["-p", port.as_str()]);
|
||||||
|
}
|
||||||
|
if t.username.is_empty() {
|
||||||
|
args.push("-N");
|
||||||
|
}
|
||||||
|
let dir;
|
||||||
|
if !t.path.trim_matches('/').is_empty() {
|
||||||
|
dir = t.path.trim_matches('/').to_string();
|
||||||
|
args.extend(["-D", dir.as_str()]);
|
||||||
|
}
|
||||||
|
args.extend(["-c", command]);
|
||||||
|
self.runner
|
||||||
|
.run_env("smbclient", &args, &[("PASSWD", t.password.as_str())])
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ftp_url(t: &BackupTarget, name: &str) -> String {
|
||||||
|
let port = t.port.map(|p| format!(":{p}")).unwrap_or_default();
|
||||||
|
let path = join_path(&t.path, name);
|
||||||
|
format!("ftp://{}{port}/{path}", t.host)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn curl(
|
||||||
|
&self,
|
||||||
|
t: &BackupTarget,
|
||||||
|
extra: &[&str],
|
||||||
|
) -> Result<crate::host::Output, DomainError> {
|
||||||
|
// credentials via a netrc file so they never appear in the process list
|
||||||
|
let netrc = std::env::temp_dir().join(format!("monitoring-netrc-{}", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::write(
|
||||||
|
&netrc,
|
||||||
|
format!(
|
||||||
|
"machine {} login {} password {}\n",
|
||||||
|
t.host, t.username, t.password
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map_err(|e| DomainError::Storage(e.to_string()))?;
|
||||||
|
let _ =
|
||||||
|
std::fs::set_permissions(&netrc, std::os::unix::fs::PermissionsExt::from_mode(0o600));
|
||||||
|
let netrc_s = netrc.to_string_lossy().to_string();
|
||||||
|
let mut args = vec!["-sS", "--fail", "--netrc-file", netrc_s.as_str()];
|
||||||
|
if t.tls {
|
||||||
|
args.push("--ssl-reqd");
|
||||||
|
}
|
||||||
|
args.extend(extra);
|
||||||
|
let res = self.runner.run("curl", &args).await;
|
||||||
|
let _ = std::fs::remove_file(&netrc);
|
||||||
|
res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `smbclient -c ls` lines: ` name A 1234 Tue Sep 2 ...`
|
||||||
|
pub fn parse_smb_ls(out: &str) -> Vec<RemoteFile> {
|
||||||
|
out.lines()
|
||||||
|
.filter_map(|l| {
|
||||||
|
let l = l.trim_end();
|
||||||
|
if !l.starts_with(" ") || l.contains("blocks of size") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// attributes column is a short uppercase token ("A", "D", "AH"...), size follows it
|
||||||
|
let parts: Vec<&str> = l.split_whitespace().collect();
|
||||||
|
let idx = parts
|
||||||
|
.iter()
|
||||||
|
.position(|p| p.len() <= 3 && p.chars().all(|c| c.is_ascii_uppercase()))?;
|
||||||
|
if idx == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = parts[..idx].join(" ");
|
||||||
|
if name == "." || name == ".." || parts[idx].contains('D') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let size = parts.get(idx + 1)?.parse().ok()?;
|
||||||
|
Some(RemoteFile {
|
||||||
|
name,
|
||||||
|
size_bytes: size,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unix-style FTP `LIST` lines: `-rw-r--r-- 1 u g 1234 Sep 2 10:00 name`
|
||||||
|
pub fn parse_ftp_list(out: &str) -> Vec<RemoteFile> {
|
||||||
|
out.lines()
|
||||||
|
.filter_map(|l| {
|
||||||
|
let parts: Vec<&str> = l.split_whitespace().collect();
|
||||||
|
if parts.len() < 9 || !parts[0].starts_with('-') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(RemoteFile {
|
||||||
|
name: parts[8..].join(" "),
|
||||||
|
size_bytes: parts[4].parse().ok()?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BackupStorage for CommandBackupStorage {
|
||||||
|
async fn test(&self, t: &BackupTarget) -> Result<(), DomainError> {
|
||||||
|
self.list(t).await.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload(
|
||||||
|
&self,
|
||||||
|
t: &BackupTarget,
|
||||||
|
local: &Path,
|
||||||
|
remote_name: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let local_s = local.to_string_lossy().to_string();
|
||||||
|
let out = match t.kind {
|
||||||
|
StorageKind::Smb => {
|
||||||
|
self.smb(t, &format!("put \"{local_s}\" \"{remote_name}\""))
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
StorageKind::Ftp => {
|
||||||
|
self.curl(
|
||||||
|
t,
|
||||||
|
&[
|
||||||
|
"--ftp-create-dirs",
|
||||||
|
"-T",
|
||||||
|
&local_s,
|
||||||
|
&Self::ftp_url(t, remote_name),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.success
|
||||||
|
.then_some(())
|
||||||
|
.ok_or_else(|| unavailable("upload failed", &out))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self, t: &BackupTarget) -> Result<Vec<RemoteFile>, DomainError> {
|
||||||
|
let out = match t.kind {
|
||||||
|
StorageKind::Smb => self.smb(t, "ls").await?,
|
||||||
|
StorageKind::Ftp => self.curl(t, &[&Self::ftp_url(t, "")]).await?,
|
||||||
|
};
|
||||||
|
if !out.success {
|
||||||
|
return Err(unavailable("listing failed", &out));
|
||||||
|
}
|
||||||
|
Ok(match t.kind {
|
||||||
|
StorageKind::Smb => parse_smb_ls(&out.stdout),
|
||||||
|
StorageKind::Ftp => parse_ftp_list(&out.stdout),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, t: &BackupTarget, remote_name: &str) -> Result<(), DomainError> {
|
||||||
|
let out = match t.kind {
|
||||||
|
StorageKind::Smb => self.smb(t, &format!("del \"{remote_name}\"")).await?,
|
||||||
|
StorageKind::Ftp => {
|
||||||
|
let dele = format!("DELE {}", join_path(&t.path, remote_name));
|
||||||
|
self.curl(
|
||||||
|
t,
|
||||||
|
&[
|
||||||
|
"-Q",
|
||||||
|
&dele,
|
||||||
|
&Self::ftp_url(
|
||||||
|
&BackupTarget {
|
||||||
|
path: String::new(),
|
||||||
|
..t.clone()
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.success
|
||||||
|
.then_some(())
|
||||||
|
.ok_or_else(|| unavailable("delete failed", &out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- collector
|
||||||
|
|
||||||
|
pub struct KubeBackupCollector {
|
||||||
|
runner: Arc<dyn CommandRunner>,
|
||||||
|
/// kubectl invocation, e.g. `["/snap/bin/microk8s", "kubectl"]`.
|
||||||
|
kubectl: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KubeBackupCollector {
|
||||||
|
pub fn new(runner: Arc<dyn CommandRunner>, kubectl: Vec<String>) -> Self {
|
||||||
|
Self { runner, kubectl }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn kubectl(&self, args: &[&str]) -> Result<crate::host::Output, DomainError> {
|
||||||
|
let mut all: Vec<&str> = self.kubectl.iter().skip(1).map(String::as_str).collect();
|
||||||
|
all.extend(args);
|
||||||
|
self.runner.run(&self.kubectl[0], &all).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn kubectl_to_file(
|
||||||
|
&self,
|
||||||
|
args: &[&str],
|
||||||
|
file: &Path,
|
||||||
|
) -> Result<crate::host::Output, DomainError> {
|
||||||
|
let mut all: Vec<&str> = self.kubectl.iter().skip(1).map(String::as_str).collect();
|
||||||
|
all.extend(args);
|
||||||
|
self.runner.run_to_file(&self.kubectl[0], &all, file).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tar_dir(&self, dir: &str, file: &Path, out: &dyn LineSink) -> Result<(), DomainError> {
|
||||||
|
let f = file.to_string_lossy().to_string();
|
||||||
|
out.line(&format!("$ tar -czf {f} -C {dir} ."));
|
||||||
|
let res = self
|
||||||
|
.runner
|
||||||
|
.run("tar", &["-czf", &f, "-C", dir, "."])
|
||||||
|
.await?;
|
||||||
|
res.success
|
||||||
|
.then_some(())
|
||||||
|
.ok_or_else(|| unavailable("tar failed", &res))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn gzip(&self, file: &Path) -> Result<PathBuf, DomainError> {
|
||||||
|
let f = file.to_string_lossy().to_string();
|
||||||
|
let res = self.runner.run("gzip", &["-f", &f]).await?;
|
||||||
|
res.success
|
||||||
|
.then_some(file.with_extension(format!(
|
||||||
|
"{}.gz",
|
||||||
|
file.extension().and_then(|e| e.to_str()).unwrap_or("")
|
||||||
|
)))
|
||||||
|
.ok_or_else(|| unavailable("gzip failed", &res))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BackupCollector for KubeBackupCollector {
|
||||||
|
async fn collect(
|
||||||
|
&self,
|
||||||
|
source: &BackupSource,
|
||||||
|
work_dir: &Path,
|
||||||
|
out: &dyn LineSink,
|
||||||
|
) -> Result<PathBuf, DomainError> {
|
||||||
|
match source {
|
||||||
|
BackupSource::VolumeClaim { namespace, pvc } => {
|
||||||
|
out.line(&format!("resolving host path of pvc {namespace}/{pvc}"));
|
||||||
|
let pv = self
|
||||||
|
.kubectl(&[
|
||||||
|
"get",
|
||||||
|
"pvc",
|
||||||
|
"-n",
|
||||||
|
namespace,
|
||||||
|
pvc,
|
||||||
|
"-o",
|
||||||
|
"jsonpath={.spec.volumeName}",
|
||||||
|
])
|
||||||
|
.await?;
|
||||||
|
if !pv.success || pv.stdout.trim().is_empty() {
|
||||||
|
return Err(unavailable("pvc lookup failed", &pv));
|
||||||
|
}
|
||||||
|
let path = self
|
||||||
|
.kubectl(&[
|
||||||
|
"get",
|
||||||
|
"pv",
|
||||||
|
pv.stdout.trim(),
|
||||||
|
"-o",
|
||||||
|
"jsonpath={.spec.hostPath.path}",
|
||||||
|
])
|
||||||
|
.await?;
|
||||||
|
let dir = path.stdout.trim().to_string();
|
||||||
|
if !path.success || dir.is_empty() {
|
||||||
|
return Err(DomainError::Unavailable(
|
||||||
|
"pv has no hostPath (only hostpath volumes are supported)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let file = work_dir.join("volume.tar.gz");
|
||||||
|
self.tar_dir(&dir, &file, out).await?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
BackupSource::PostgresDump { namespace, pod } => {
|
||||||
|
out.line(&format!(
|
||||||
|
"$ kubectl exec -n {namespace} {pod} -- pg_dumpall"
|
||||||
|
));
|
||||||
|
let raw = work_dir.join("dump.sql");
|
||||||
|
let res = self
|
||||||
|
.kubectl_to_file(&["exec", "-n", namespace, pod, "--", "sh", "-c", "PGPASSWORD=\"${POSTGRES_PASSWORD:-$POSTGRESQL_PASSWORD}\" pg_dumpall -U postgres"], &raw)
|
||||||
|
.await?;
|
||||||
|
if !res.success {
|
||||||
|
return Err(unavailable("pg_dumpall failed", &res));
|
||||||
|
}
|
||||||
|
self.gzip(&raw).await
|
||||||
|
}
|
||||||
|
BackupSource::KubernetesManifests { namespace } => {
|
||||||
|
out.line(&format!("$ kubectl get all,configmap,secret,ingress,pvc,serviceaccount -n {namespace} -o yaml"));
|
||||||
|
let raw = work_dir.join("manifests.yaml");
|
||||||
|
let res = self
|
||||||
|
.kubectl_to_file(
|
||||||
|
&[
|
||||||
|
"get",
|
||||||
|
"all,configmap,secret,ingress,pvc,serviceaccount",
|
||||||
|
"-n",
|
||||||
|
namespace,
|
||||||
|
"-o",
|
||||||
|
"yaml",
|
||||||
|
],
|
||||||
|
&raw,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !res.success {
|
||||||
|
return Err(unavailable("kubectl get failed", &res));
|
||||||
|
}
|
||||||
|
self.gzip(&raw).await
|
||||||
|
}
|
||||||
|
BackupSource::HostPath { path } => {
|
||||||
|
let file = work_dir.join("hostpath.tar.gz");
|
||||||
|
self.tar_dir(path, &file, out).await?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- encryption
|
||||||
|
|
||||||
|
/// `openssl enc -aes-256-cbc -pbkdf2`; decrypt with
|
||||||
|
/// `openssl enc -d -aes-256-cbc -pbkdf2 -in FILE.enc -out FILE`.
|
||||||
|
pub struct OpensslEncryptor {
|
||||||
|
runner: Arc<dyn CommandRunner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpensslEncryptor {
|
||||||
|
pub fn new(runner: Arc<dyn CommandRunner>) -> Self {
|
||||||
|
Self { runner }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FileEncryptor for OpensslEncryptor {
|
||||||
|
async fn encrypt(&self, input: &Path, passphrase: &str) -> Result<PathBuf, DomainError> {
|
||||||
|
let out = PathBuf::from(format!("{}.enc", input.to_string_lossy()));
|
||||||
|
let (i, o) = (
|
||||||
|
input.to_string_lossy().to_string(),
|
||||||
|
out.to_string_lossy().to_string(),
|
||||||
|
);
|
||||||
|
let res = self
|
||||||
|
.runner
|
||||||
|
.run_env(
|
||||||
|
"openssl",
|
||||||
|
&[
|
||||||
|
"enc",
|
||||||
|
"-aes-256-cbc",
|
||||||
|
"-pbkdf2",
|
||||||
|
"-salt",
|
||||||
|
"-in",
|
||||||
|
&i,
|
||||||
|
"-out",
|
||||||
|
&o,
|
||||||
|
"-pass",
|
||||||
|
"env:BACKUP_PASSPHRASE",
|
||||||
|
],
|
||||||
|
&[("BACKUP_PASSPHRASE", passphrase)],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !res.success {
|
||||||
|
return Err(unavailable("openssl enc failed", &res));
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(input);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- fakes
|
||||||
|
|
||||||
|
/// Stores "remote" files in a local directory per target (FAKE_HOST=true).
|
||||||
|
pub struct DirBackupStorage {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DirBackupStorage {
|
||||||
|
pub fn new(root: PathBuf) -> Self {
|
||||||
|
Self { root }
|
||||||
|
}
|
||||||
|
fn dir(&self, t: &BackupTarget) -> Result<PathBuf, DomainError> {
|
||||||
|
let d = self.root.join(t.id.to_string());
|
||||||
|
std::fs::create_dir_all(&d).map_err(|e| DomainError::Storage(e.to_string()))?;
|
||||||
|
Ok(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BackupStorage for DirBackupStorage {
|
||||||
|
async fn test(&self, t: &BackupTarget) -> Result<(), DomainError> {
|
||||||
|
if t.host.contains("unreachable") {
|
||||||
|
return Err(DomainError::Unavailable("connection refused".into()));
|
||||||
|
}
|
||||||
|
self.dir(t).map(|_| ())
|
||||||
|
}
|
||||||
|
async fn upload(
|
||||||
|
&self,
|
||||||
|
t: &BackupTarget,
|
||||||
|
local: &Path,
|
||||||
|
remote_name: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
std::fs::copy(local, self.dir(t)?.join(remote_name))
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| DomainError::Storage(e.to_string()))
|
||||||
|
}
|
||||||
|
async fn list(&self, t: &BackupTarget) -> Result<Vec<RemoteFile>, DomainError> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
for e in std::fs::read_dir(self.dir(t)?)
|
||||||
|
.map_err(|e| DomainError::Storage(e.to_string()))?
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
if let Ok(m) = e.metadata() {
|
||||||
|
v.push(RemoteFile {
|
||||||
|
name: e.file_name().to_string_lossy().to_string(),
|
||||||
|
size_bytes: m.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
async fn delete(&self, t: &BackupTarget, remote_name: &str) -> Result<(), DomainError> {
|
||||||
|
std::fs::remove_file(self.dir(t)?.join(remote_name))
|
||||||
|
.map_err(|e| DomainError::Storage(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produces a small archive describing the source (FAKE_HOST=true).
|
||||||
|
pub struct FakeBackupCollector;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BackupCollector for FakeBackupCollector {
|
||||||
|
async fn collect(
|
||||||
|
&self,
|
||||||
|
source: &BackupSource,
|
||||||
|
work_dir: &Path,
|
||||||
|
out: &dyn LineSink,
|
||||||
|
) -> Result<PathBuf, DomainError> {
|
||||||
|
out.line(&format!(
|
||||||
|
"fake: collecting {}",
|
||||||
|
serde_json::to_string(source).unwrap_or_default()
|
||||||
|
));
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
let file = work_dir.join(format!("archive.{}", source.extension()));
|
||||||
|
std::fs::write(&file, format!("fake backup of {source:?}\n"))
|
||||||
|
.map_err(|e| DomainError::Storage(e.to_string()))?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_smbclient_listing() {
|
||||||
|
let out = " . D 0 Tue Sep 2 10:00:00 2026\n .. D 0 Tue Sep 2 10:00:00 2026\n gitea-db_20260902-020000.sql.gz A 12345 Tue Sep 2 02:00:05 2026\n my file.tar.gz A 99 Tue Sep 2 02:00:05 2026\n\n\t\t1234567 blocks of size 1024. 999 blocks available\n";
|
||||||
|
let files = parse_smb_ls(out);
|
||||||
|
assert_eq!(
|
||||||
|
files,
|
||||||
|
vec![
|
||||||
|
RemoteFile {
|
||||||
|
name: "gitea-db_20260902-020000.sql.gz".into(),
|
||||||
|
size_bytes: 12345
|
||||||
|
},
|
||||||
|
RemoteFile {
|
||||||
|
name: "my file.tar.gz".into(),
|
||||||
|
size_bytes: 99
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ftp_listing() {
|
||||||
|
let out = "drwxr-xr-x 2 ftp ftp 4096 Sep 02 02:00 sub\n-rw-r--r-- 1 ftp ftp 12345 Sep 02 02:00 gitea-db_20260902-020000.sql.gz\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_ftp_list(out),
|
||||||
|
vec![RemoteFile {
|
||||||
|
name: "gitea-db_20260902-020000.sql.gz".into(),
|
||||||
|
size_bytes: 12345
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Call = (String, Vec<String>, Vec<(String, String)>);
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Rec(Mutex<Vec<Call>>);
|
||||||
|
#[async_trait]
|
||||||
|
impl CommandRunner for Rec {
|
||||||
|
async fn run(&self, p: &str, a: &[&str]) -> Result<crate::host::Output, DomainError> {
|
||||||
|
self.run_env(p, a, &[]).await
|
||||||
|
}
|
||||||
|
async fn run_env(
|
||||||
|
&self,
|
||||||
|
p: &str,
|
||||||
|
a: &[&str],
|
||||||
|
env: &[(&str, &str)],
|
||||||
|
) -> Result<crate::host::Output, DomainError> {
|
||||||
|
self.0.lock().unwrap().push((
|
||||||
|
p.into(),
|
||||||
|
a.iter().map(|s| s.to_string()).collect(),
|
||||||
|
env.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||||
|
.collect(),
|
||||||
|
));
|
||||||
|
Ok(crate::host::Output {
|
||||||
|
stdout: "pvc-123\n".into(),
|
||||||
|
success: true,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async fn run_to_file(
|
||||||
|
&self,
|
||||||
|
p: &str,
|
||||||
|
a: &[&str],
|
||||||
|
f: &Path,
|
||||||
|
) -> Result<crate::host::Output, DomainError> {
|
||||||
|
std::fs::write(f, "data").unwrap();
|
||||||
|
self.run_env(p, a, &[]).await
|
||||||
|
}
|
||||||
|
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
async fn run_streaming(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
_: &[&str],
|
||||||
|
_: &dyn LineSink,
|
||||||
|
) -> Result<bool, DomainError> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Sink;
|
||||||
|
impl LineSink for Sink {
|
||||||
|
fn line(&self, _: &str) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn smb_target() -> BackupTarget {
|
||||||
|
BackupTarget {
|
||||||
|
id: uuid::Uuid::new_v4(),
|
||||||
|
name: "n".into(),
|
||||||
|
kind: StorageKind::Smb,
|
||||||
|
host: "nas".into(),
|
||||||
|
port: None,
|
||||||
|
share: "backups".into(),
|
||||||
|
path: "softvisor".into(),
|
||||||
|
username: "u".into(),
|
||||||
|
password: "p".into(),
|
||||||
|
tls: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn smb_upload_passes_password_via_env_not_argv() {
|
||||||
|
let r = Arc::new(Rec::default());
|
||||||
|
CommandBackupStorage::new(r.clone())
|
||||||
|
.upload(&smb_target(), Path::new("/tmp/x.tar.gz"), "x.tar.gz")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let calls = r.0.lock().unwrap();
|
||||||
|
let (prog, args, env) = &calls[0];
|
||||||
|
assert_eq!(prog, "smbclient");
|
||||||
|
assert_eq!(args[0], "//nas/backups");
|
||||||
|
assert!(args.contains(&"-D".to_string()) && args.contains(&"softvisor".to_string()));
|
||||||
|
assert!(args.iter().any(|a| a.starts_with("put ")), "{args:?}");
|
||||||
|
assert!(!args.iter().any(|a| a.contains('p') && a.len() == 1));
|
||||||
|
assert_eq!(env[0], ("PASSWD".to_string(), "p".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ftp_uses_curl_with_netrc_and_tls_flag() {
|
||||||
|
let r = Arc::new(Rec::default());
|
||||||
|
let mut t = smb_target();
|
||||||
|
t.kind = StorageKind::Ftp;
|
||||||
|
t.tls = true;
|
||||||
|
t.port = Some(2121);
|
||||||
|
CommandBackupStorage::new(r.clone())
|
||||||
|
.upload(&t, Path::new("/tmp/x"), "x")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let calls = r.0.lock().unwrap();
|
||||||
|
let (prog, args, _) = &calls[0];
|
||||||
|
assert_eq!(prog, "curl");
|
||||||
|
assert!(args.contains(&"--netrc-file".to_string()));
|
||||||
|
assert!(args.contains(&"--ssl-reqd".to_string()));
|
||||||
|
assert!(
|
||||||
|
args.last().unwrap().ends_with("ftp://nas:2121/softvisor/x"),
|
||||||
|
"{args:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!args.iter().any(|a| a.contains("u:p")),
|
||||||
|
"no credentials in argv"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn collector_builds_kubectl_and_tar_commands() {
|
||||||
|
let r = Arc::new(Rec::default());
|
||||||
|
let c = KubeBackupCollector::new(
|
||||||
|
r.clone(),
|
||||||
|
vec!["/snap/bin/microk8s".into(), "kubectl".into()],
|
||||||
|
);
|
||||||
|
let work = tempfile::tempdir().unwrap();
|
||||||
|
let f = c
|
||||||
|
.collect(
|
||||||
|
&BackupSource::VolumeClaim {
|
||||||
|
namespace: "gitea".into(),
|
||||||
|
pvc: "data".into(),
|
||||||
|
},
|
||||||
|
work.path(),
|
||||||
|
&Sink,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(f.ends_with("volume.tar.gz"));
|
||||||
|
let f = c
|
||||||
|
.collect(
|
||||||
|
&BackupSource::PostgresDump {
|
||||||
|
namespace: "gitea".into(),
|
||||||
|
pod: "pg-0".into(),
|
||||||
|
},
|
||||||
|
work.path(),
|
||||||
|
&Sink,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(f.ends_with("dump.sql.gz"), "{f:?}");
|
||||||
|
let calls = r.0.lock().unwrap();
|
||||||
|
assert_eq!(calls[0].0, "/snap/bin/microk8s");
|
||||||
|
assert_eq!(calls[0].1[..3], ["kubectl", "get", "pvc"]);
|
||||||
|
assert_eq!(calls[2].0, "tar");
|
||||||
|
let exec = calls
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.1.contains(&"exec".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
assert!(exec.1.iter().any(|a| a.contains("pg_dumpall")));
|
||||||
|
assert!(calls.iter().any(|c| c.0 == "gzip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openssl_receives_passphrase_via_env() {
|
||||||
|
let r = Arc::new(Rec::default());
|
||||||
|
let work = tempfile::tempdir().unwrap();
|
||||||
|
let input = work.path().join("a.tar.gz");
|
||||||
|
std::fs::write(&input, "x").unwrap();
|
||||||
|
let out = OpensslEncryptor::new(r.clone())
|
||||||
|
.encrypt(&input, "pw")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.to_string_lossy().ends_with("a.tar.gz.enc"));
|
||||||
|
let calls = r.0.lock().unwrap();
|
||||||
|
assert_eq!(calls[0].0, "openssl");
|
||||||
|
assert!(calls[0].1.contains(&"env:BACKUP_PASSPHRASE".to_string()));
|
||||||
|
assert_eq!(calls[0].2[0].1, "pw");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dir_storage_roundtrip() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let s = DirBackupStorage::new(root.path().to_path_buf());
|
||||||
|
let t = smb_target();
|
||||||
|
let local = root.path().join("local.bin");
|
||||||
|
std::fs::write(&local, "hello").unwrap();
|
||||||
|
s.upload(&t, &local, "remote.bin").await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
s.list(&t).await.unwrap(),
|
||||||
|
vec![RemoteFile {
|
||||||
|
name: "remote.bin".into(),
|
||||||
|
size_bytes: 5
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
s.delete(&t, "remote.bin").await.unwrap();
|
||||||
|
assert!(s.list(&t).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +13,20 @@ pub struct Output {
|
|||||||
pub trait CommandRunner: Send + Sync {
|
pub trait CommandRunner: Send + Sync {
|
||||||
async fn run(&self, program: &str, args: &[&str]) -> Result<Output, DomainError>;
|
async fn run(&self, program: &str, args: &[&str]) -> Result<Output, DomainError>;
|
||||||
async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError>;
|
async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError>;
|
||||||
|
/// Run with extra environment variables (used to pass secrets without exposing them in argv).
|
||||||
|
async fn run_env(
|
||||||
|
&self,
|
||||||
|
program: &str,
|
||||||
|
args: &[&str],
|
||||||
|
env: &[(&str, &str)],
|
||||||
|
) -> Result<Output, DomainError>;
|
||||||
|
/// Run a command and write its stdout to `file`; stderr is captured in the result.
|
||||||
|
async fn run_to_file(
|
||||||
|
&self,
|
||||||
|
program: &str,
|
||||||
|
args: &[&str],
|
||||||
|
file: &std::path::Path,
|
||||||
|
) -> Result<Output, DomainError>;
|
||||||
/// Run a command and forward each output line (stdout and stderr) to `out`.
|
/// Run a command and forward each output line (stdout and stderr) to `out`.
|
||||||
async fn run_streaming(
|
async fn run_streaming(
|
||||||
&self,
|
&self,
|
||||||
@ -27,10 +41,24 @@ pub struct SystemCommandRunner;
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl CommandRunner for SystemCommandRunner {
|
impl CommandRunner for SystemCommandRunner {
|
||||||
async fn run(&self, program: &str, args: &[&str]) -> Result<Output, DomainError> {
|
async fn run(&self, program: &str, args: &[&str]) -> Result<Output, DomainError> {
|
||||||
let out = tokio::process::Command::new(program)
|
self.run_env(program, args, &[]).await
|
||||||
.args(args)
|
}
|
||||||
|
|
||||||
|
async fn run_env(
|
||||||
|
&self,
|
||||||
|
program: &str,
|
||||||
|
args: &[&str],
|
||||||
|
env: &[(&str, &str)],
|
||||||
|
) -> Result<Output, DomainError> {
|
||||||
|
let mut cmd = tokio::process::Command::new(program);
|
||||||
|
cmd.args(args)
|
||||||
.env("DEBIAN_FRONTEND", "noninteractive")
|
.env("DEBIAN_FRONTEND", "noninteractive")
|
||||||
.env("LC_ALL", "C")
|
.env("LC_ALL", "C")
|
||||||
|
.stdin(std::process::Stdio::null());
|
||||||
|
for (k, v) in env {
|
||||||
|
cmd.env(k, v);
|
||||||
|
}
|
||||||
|
let out = cmd
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Unavailable(format!("{program}: {e}")))?;
|
.map_err(|e| DomainError::Unavailable(format!("{program}: {e}")))?;
|
||||||
@ -41,6 +69,30 @@ impl CommandRunner for SystemCommandRunner {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_to_file(
|
||||||
|
&self,
|
||||||
|
program: &str,
|
||||||
|
args: &[&str],
|
||||||
|
file: &std::path::Path,
|
||||||
|
) -> Result<Output, DomainError> {
|
||||||
|
let f = std::fs::File::create(file)
|
||||||
|
.map_err(|e| DomainError::Storage(format!("{}: {e}", file.display())))?;
|
||||||
|
let out = tokio::process::Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.env("LC_ALL", "C")
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::from(f))
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Unavailable(format!("{program}: {e}")))?;
|
||||||
|
Ok(Output {
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||||
|
success: out.status.success(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_streaming(
|
async fn run_streaming(
|
||||||
&self,
|
&self,
|
||||||
program: &str,
|
program: &str,
|
||||||
|
|||||||
@ -237,6 +237,22 @@ Conf openssl (3.0.16-1~deb12u1 Debian-Security:12/stable-security [amd64])\n";
|
|||||||
) -> Result<bool, DomainError> {
|
) -> Result<bool, DomainError> {
|
||||||
unreachable!()
|
unreachable!()
|
||||||
}
|
}
|
||||||
|
async fn run_env(
|
||||||
|
&self,
|
||||||
|
p: &str,
|
||||||
|
a: &[&str],
|
||||||
|
_: &[(&str, &str)],
|
||||||
|
) -> Result<super::super::Output, DomainError> {
|
||||||
|
self.run(p, a).await
|
||||||
|
}
|
||||||
|
async fn run_to_file(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
_: &[&str],
|
||||||
|
_: &std::path::Path,
|
||||||
|
) -> Result<super::super::Output, DomainError> {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError> {
|
async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError> {
|
||||||
Ok(match path {
|
Ok(match path {
|
||||||
"/etc/os-release" => Some(
|
"/etc/os-release" => Some(
|
||||||
|
|||||||
@ -79,6 +79,22 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
async fn run_env(
|
||||||
|
&self,
|
||||||
|
p: &str,
|
||||||
|
a: &[&str],
|
||||||
|
_: &[(&str, &str)],
|
||||||
|
) -> Result<crate::host::Output, DomainError> {
|
||||||
|
self.run(p, a).await
|
||||||
|
}
|
||||||
|
async fn run_to_file(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
_: &[&str],
|
||||||
|
_: &std::path::Path,
|
||||||
|
) -> Result<crate::host::Output, DomainError> {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
|
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
//! Infrastructure layer: SQLite repositories, Argon2 hashing, JWT issuing.
|
//! Infrastructure layer: SQLite repositories, Argon2 hashing, JWT issuing.
|
||||||
|
pub mod backup;
|
||||||
pub mod cipher;
|
pub mod cipher;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod host;
|
pub mod host;
|
||||||
@ -9,6 +10,10 @@ pub mod sqlite;
|
|||||||
pub mod token;
|
pub mod token;
|
||||||
pub mod trivy;
|
pub mod trivy;
|
||||||
|
|
||||||
|
pub use backup::{
|
||||||
|
CommandBackupStorage, DirBackupStorage, FakeBackupCollector, KubeBackupCollector,
|
||||||
|
OpensslEncryptor,
|
||||||
|
};
|
||||||
pub use cipher::AesGcmCipher;
|
pub use cipher::AesGcmCipher;
|
||||||
pub use db::{connect, DbPool};
|
pub use db::{connect, DbPool};
|
||||||
pub use host::{
|
pub use host::{
|
||||||
@ -18,6 +23,9 @@ pub use k8s::{FakeClusterGateway, KubeGateway};
|
|||||||
pub use mail::LettreMailer;
|
pub use mail::LettreMailer;
|
||||||
pub use password::Argon2Hasher;
|
pub use password::Argon2Hasher;
|
||||||
pub use sqlite::{SqliteAuditLog, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers};
|
pub use sqlite::{SqliteAuditLog, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers};
|
||||||
pub use sqlite::{SqliteFindings, SqliteInventory};
|
pub use sqlite::{
|
||||||
|
SqliteBackupRecords, SqliteBackupStrategies, SqliteBackupTargets, SqliteFindings,
|
||||||
|
SqliteInventory,
|
||||||
|
};
|
||||||
pub use token::JwtIssuer;
|
pub use token::JwtIssuer;
|
||||||
pub use trivy::{FakeScanner, TrivyScanner};
|
pub use trivy::{FakeScanner, TrivyScanner};
|
||||||
|
|||||||
@ -753,3 +753,307 @@ mod finding_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use domain::backup::{BackupRecord, BackupSource, BackupStrategy, BackupTarget, StorageKind};
|
||||||
|
|
||||||
|
pub struct SqliteBackupTargets(pub DbPool);
|
||||||
|
|
||||||
|
fn target_from_row(r: &SqliteRow) -> BackupTarget {
|
||||||
|
BackupTarget {
|
||||||
|
id: r.get("id"),
|
||||||
|
name: r.get("name"),
|
||||||
|
kind: StorageKind::parse(r.get::<String, _>("kind").as_str()).unwrap_or(StorageKind::Smb),
|
||||||
|
host: r.get("host"),
|
||||||
|
port: r.get::<Option<i64>, _>("port").map(|p| p as u16),
|
||||||
|
share: r.get("share"),
|
||||||
|
path: r.get("path"),
|
||||||
|
username: r.get("username"),
|
||||||
|
password: r.get("password"),
|
||||||
|
tls: r.get("tls"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TARGET_COLS: &str = "id, name, kind, host, port, share, path, username, password, tls";
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl domain::ports::BackupTargetRepository for SqliteBackupTargets {
|
||||||
|
async fn list(&self) -> Result<Vec<BackupTarget>, DomainError> {
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"SELECT {TARGET_COLS} FROM backup_targets ORDER BY name"
|
||||||
|
))
|
||||||
|
.fetch_all(&self.0)
|
||||||
|
.await
|
||||||
|
.map(|rows| rows.iter().map(target_from_row).collect())
|
||||||
|
.map_err(storage)
|
||||||
|
}
|
||||||
|
async fn get(&self, id: Uuid) -> Result<Option<BackupTarget>, DomainError> {
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"SELECT {TARGET_COLS} FROM backup_targets WHERE id = ?"
|
||||||
|
))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.0)
|
||||||
|
.await
|
||||||
|
.map(|r| r.as_ref().map(target_from_row))
|
||||||
|
.map_err(storage)
|
||||||
|
}
|
||||||
|
async fn insert(&self, t: &BackupTarget) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"INSERT INTO backup_targets ({TARGET_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
))
|
||||||
|
.bind(t.id)
|
||||||
|
.bind(&t.name)
|
||||||
|
.bind(t.kind.as_str())
|
||||||
|
.bind(&t.host)
|
||||||
|
.bind(t.port.map(|p| p as i64))
|
||||||
|
.bind(&t.share)
|
||||||
|
.bind(&t.path)
|
||||||
|
.bind(&t.username)
|
||||||
|
.bind(&t.password)
|
||||||
|
.bind(t.tls)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(storage)
|
||||||
|
}
|
||||||
|
async fn update(&self, t: &BackupTarget) -> Result<(), DomainError> {
|
||||||
|
let res = sqlx::query("UPDATE backup_targets SET name = ?, kind = ?, host = ?, port = ?, share = ?, path = ?, username = ?, password = ?, tls = ? WHERE id = ?")
|
||||||
|
.bind(&t.name)
|
||||||
|
.bind(t.kind.as_str())
|
||||||
|
.bind(&t.host)
|
||||||
|
.bind(t.port.map(|p| p as i64))
|
||||||
|
.bind(&t.share)
|
||||||
|
.bind(&t.path)
|
||||||
|
.bind(&t.username)
|
||||||
|
.bind(&t.password)
|
||||||
|
.bind(t.tls)
|
||||||
|
.bind(t.id)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.map_err(storage)?;
|
||||||
|
(res.rows_affected() > 0)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
|
||||||
|
let res = sqlx::query("DELETE FROM backup_targets WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.map_err(storage)?;
|
||||||
|
(res.rows_affected() > 0)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SqliteBackupStrategies(pub DbPool);
|
||||||
|
|
||||||
|
fn strategy_from_row(r: &SqliteRow) -> Result<BackupStrategy, DomainError> {
|
||||||
|
Ok(BackupStrategy {
|
||||||
|
id: r.get("id"),
|
||||||
|
name: r.get("name"),
|
||||||
|
source: serde_json::from_str::<BackupSource>(r.get::<String, _>("source").as_str())
|
||||||
|
.map_err(|e| DomainError::Storage(e.to_string()))?,
|
||||||
|
schedule: r.get("schedule"),
|
||||||
|
target_id: r.get("target_id"),
|
||||||
|
retention: r.get::<i64, _>("retention") as u32,
|
||||||
|
passphrase: r.get("passphrase"),
|
||||||
|
enabled: r.get("enabled"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRATEGY_COLS: &str = "id, name, source, schedule, target_id, retention, passphrase, enabled";
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl domain::ports::BackupStrategyRepository for SqliteBackupStrategies {
|
||||||
|
async fn list(&self) -> Result<Vec<BackupStrategy>, DomainError> {
|
||||||
|
let rows = sqlx::query(&format!(
|
||||||
|
"SELECT {STRATEGY_COLS} FROM backup_strategies ORDER BY name"
|
||||||
|
))
|
||||||
|
.fetch_all(&self.0)
|
||||||
|
.await
|
||||||
|
.map_err(storage)?;
|
||||||
|
rows.iter().map(strategy_from_row).collect()
|
||||||
|
}
|
||||||
|
async fn get(&self, id: Uuid) -> Result<Option<BackupStrategy>, DomainError> {
|
||||||
|
let row = sqlx::query(&format!(
|
||||||
|
"SELECT {STRATEGY_COLS} FROM backup_strategies WHERE id = ?"
|
||||||
|
))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.0)
|
||||||
|
.await
|
||||||
|
.map_err(storage)?;
|
||||||
|
row.as_ref().map(strategy_from_row).transpose()
|
||||||
|
}
|
||||||
|
async fn insert(&self, s: &BackupStrategy) -> Result<(), DomainError> {
|
||||||
|
let source =
|
||||||
|
serde_json::to_string(&s.source).map_err(|e| DomainError::Storage(e.to_string()))?;
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"INSERT INTO backup_strategies ({STRATEGY_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
))
|
||||||
|
.bind(s.id)
|
||||||
|
.bind(&s.name)
|
||||||
|
.bind(source)
|
||||||
|
.bind(&s.schedule)
|
||||||
|
.bind(s.target_id)
|
||||||
|
.bind(s.retention as i64)
|
||||||
|
.bind(&s.passphrase)
|
||||||
|
.bind(s.enabled)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(storage)
|
||||||
|
}
|
||||||
|
async fn update(&self, s: &BackupStrategy) -> Result<(), DomainError> {
|
||||||
|
let source =
|
||||||
|
serde_json::to_string(&s.source).map_err(|e| DomainError::Storage(e.to_string()))?;
|
||||||
|
let res = sqlx::query("UPDATE backup_strategies SET name = ?, source = ?, schedule = ?, target_id = ?, retention = ?, passphrase = ?, enabled = ? WHERE id = ?")
|
||||||
|
.bind(&s.name)
|
||||||
|
.bind(source)
|
||||||
|
.bind(&s.schedule)
|
||||||
|
.bind(s.target_id)
|
||||||
|
.bind(s.retention as i64)
|
||||||
|
.bind(&s.passphrase)
|
||||||
|
.bind(s.enabled)
|
||||||
|
.bind(s.id)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.map_err(storage)?;
|
||||||
|
(res.rows_affected() > 0)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
|
||||||
|
let res = sqlx::query("DELETE FROM backup_strategies WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.map_err(storage)?;
|
||||||
|
(res.rows_affected() > 0)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SqliteBackupRecords(pub DbPool);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl domain::ports::BackupRecordRepository for SqliteBackupRecords {
|
||||||
|
async fn insert(&self, r: &BackupRecord) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("INSERT INTO backup_records (id, strategy_id, filename, size_bytes, sha256, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
||||||
|
.bind(r.id)
|
||||||
|
.bind(r.strategy_id)
|
||||||
|
.bind(&r.filename)
|
||||||
|
.bind(r.size_bytes as i64)
|
||||||
|
.bind(&r.sha256)
|
||||||
|
.bind(r.created_at.to_rfc3339())
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(storage)
|
||||||
|
}
|
||||||
|
async fn list_for(
|
||||||
|
&self,
|
||||||
|
strategy_id: Uuid,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Vec<BackupRecord>, DomainError> {
|
||||||
|
sqlx::query("SELECT id, strategy_id, filename, size_bytes, sha256, created_at FROM backup_records WHERE strategy_id = ? ORDER BY created_at DESC LIMIT ?")
|
||||||
|
.bind(strategy_id)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&self.0)
|
||||||
|
.await
|
||||||
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.map(|r| BackupRecord {
|
||||||
|
id: r.get("id"),
|
||||||
|
strategy_id: r.get("strategy_id"),
|
||||||
|
filename: r.get("filename"),
|
||||||
|
size_bytes: r.get::<i64, _>("size_bytes") as u64,
|
||||||
|
sha256: r.get("sha256"),
|
||||||
|
created_at: parse_ts(r.get::<String, _>("created_at").as_str()),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.map_err(storage)
|
||||||
|
}
|
||||||
|
async fn delete_by_filename(
|
||||||
|
&self,
|
||||||
|
strategy_id: Uuid,
|
||||||
|
filename: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM backup_records WHERE strategy_id = ? AND filename = ?")
|
||||||
|
.bind(strategy_id)
|
||||||
|
.bind(filename)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(storage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod backup_tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::ports::{BackupRecordRepository, BackupStrategyRepository, BackupTargetRepository};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn targets_strategies_records_roundtrip() {
|
||||||
|
let pool = crate::connect("sqlite::memory:").await.unwrap();
|
||||||
|
let targets = SqliteBackupTargets(pool.clone());
|
||||||
|
let strategies = SqliteBackupStrategies(pool.clone());
|
||||||
|
let records = SqliteBackupRecords(pool);
|
||||||
|
let t = BackupTarget {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "NAS".into(),
|
||||||
|
kind: StorageKind::Ftp,
|
||||||
|
host: "h".into(),
|
||||||
|
port: Some(2121),
|
||||||
|
share: String::new(),
|
||||||
|
path: "p".into(),
|
||||||
|
username: "u".into(),
|
||||||
|
password: "enc".into(),
|
||||||
|
tls: true,
|
||||||
|
};
|
||||||
|
targets.insert(&t).await.unwrap();
|
||||||
|
assert_eq!(targets.get(t.id).await.unwrap().unwrap(), t);
|
||||||
|
let s = BackupStrategy {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "S".into(),
|
||||||
|
source: BackupSource::HostPath {
|
||||||
|
path: "/srv".into(),
|
||||||
|
},
|
||||||
|
schedule: "0 0 1 * * *".into(),
|
||||||
|
target_id: t.id,
|
||||||
|
retention: 3,
|
||||||
|
passphrase: Some("enc".into()),
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
strategies.insert(&s).await.unwrap();
|
||||||
|
assert_eq!(strategies.list().await.unwrap(), vec![s.clone()]);
|
||||||
|
let mut s2 = s.clone();
|
||||||
|
s2.enabled = false;
|
||||||
|
strategies.update(&s2).await.unwrap();
|
||||||
|
assert!(!strategies.get(s.id).await.unwrap().unwrap().enabled);
|
||||||
|
let r = BackupRecord {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
strategy_id: s.id,
|
||||||
|
filename: "s_1.tar.gz".into(),
|
||||||
|
size_bytes: 10,
|
||||||
|
sha256: "x".into(),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
records.insert(&r).await.unwrap();
|
||||||
|
assert_eq!(records.list_for(s.id, 10).await.unwrap().len(), 1);
|
||||||
|
records
|
||||||
|
.delete_by_filename(s.id, "s_1.tar.gz")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(records.list_for(s.id, 10).await.unwrap().is_empty());
|
||||||
|
strategies.delete(s.id).await.unwrap();
|
||||||
|
targets.delete(t.id).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
targets.delete(t.id).await.unwrap_err(),
|
||||||
|
DomainError::NotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -317,6 +317,22 @@ mod tests {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
async fn run_env(
|
||||||
|
&self,
|
||||||
|
p: &str,
|
||||||
|
a: &[&str],
|
||||||
|
_: &[(&str, &str)],
|
||||||
|
) -> Result<crate::host::Output, DomainError> {
|
||||||
|
self.run(p, a).await
|
||||||
|
}
|
||||||
|
async fn run_to_file(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
_: &[&str],
|
||||||
|
_: &std::path::Path,
|
||||||
|
) -> Result<crate::host::Output, DomainError> {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
|
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|||||||
50
docs/restore.md
Normal file
50
docs/restore.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Restoring backups
|
||||||
|
|
||||||
|
Backups are plain archives on the SMB share or FTP server, named
|
||||||
|
`<strategy-slug>_<YYYYmmdd-HHMMSS>.<ext>[.enc]`. The SHA-256 of every uploaded file is shown
|
||||||
|
in the backup history of the strategy.
|
||||||
|
|
||||||
|
## 1. Decrypt (only for `.enc` files)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl enc -d -aes-256-cbc -pbkdf2 -in FILE.sql.gz.enc -out FILE.sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Enter the passphrase of the strategy when prompted.
|
||||||
|
|
||||||
|
## 2. Restore per source type
|
||||||
|
|
||||||
|
### PostgreSQL dump (`.sql.gz`, produced by `pg_dumpall` inside the pod)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunzip -c gitea-db_*.sql.gz | microk8s kubectl exec -i -n gitea gitea-postgresql-0 -- \
|
||||||
|
sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres'
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop Gitea first (`microk8s kubectl scale deploy/gitea -n gitea --replicas=0`) and start it again afterwards.
|
||||||
|
|
||||||
|
### Persistent volume (`.tar.gz` of the hostpath directory)
|
||||||
|
|
||||||
|
Find the directory of the PVC on the host and unpack into it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PV=$(microk8s kubectl get pvc -n gitea gitea-shared-storage -o jsonpath='{.spec.volumeName}')
|
||||||
|
DIR=$(microk8s kubectl get pv "$PV" -o jsonpath='{.spec.hostPath.path}')
|
||||||
|
microk8s kubectl scale deploy/gitea -n gitea --replicas=0
|
||||||
|
tar -xzf gitea-data_*.tar.gz -C "$DIR"
|
||||||
|
microk8s kubectl scale deploy/gitea -n gitea --replicas=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kubernetes manifests (`.yaml.gz`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunzip -c gitea-manifests_*.yaml.gz | microk8s kubectl apply -f -
|
||||||
|
```
|
||||||
|
|
||||||
|
Secrets and PVC definitions are included; review before applying to a different cluster.
|
||||||
|
|
||||||
|
### Host directory (`.tar.gz`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf name_*.tar.gz -C /path/of/the/source
|
||||||
|
```
|
||||||
@ -9,26 +9,26 @@ test('admin creates a target and a strategy, runs it and sees the backup', async
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'New target' }).click()
|
await page.getByRole('button', { name: 'New target' }).click()
|
||||||
const name = `NAS ${Date.now()}`
|
const name = `NAS ${Date.now()}`
|
||||||
await page.getByLabel('Name').fill(name)
|
await page.getByLabel('Name', { exact: true }).fill(name)
|
||||||
await page.getByLabel('Host').fill('nas.local')
|
await page.getByLabel('Host', { exact: true }).fill('nas.local')
|
||||||
await page.getByLabel('Share').fill('backups')
|
await page.getByLabel('Share').fill('backups')
|
||||||
await page.getByLabel('Directory').fill('softvisor')
|
await page.getByLabel('Directory').fill('softvisor')
|
||||||
await page.getByLabel('Username').fill('backup')
|
await page.getByLabel('Username', { exact: true }).fill('backup')
|
||||||
await page.getByLabel('Password').fill('smb-secret')
|
await page.getByLabel('Password', { exact: true }).fill('smb-secret')
|
||||||
await page.getByRole('button', { name: 'Save target' }).click()
|
await page.getByRole('button', { name: 'Save target' }).click()
|
||||||
const targetRow = page.getByRole('row', { name: new RegExp(name) })
|
const targetRow = page.getByRole('row', { name: new RegExp(name) })
|
||||||
await expect(targetRow).toBeVisible()
|
await expect(targetRow).toBeVisible()
|
||||||
await targetRow.getByRole('button', { name: 'Test' }).click()
|
await targetRow.getByRole('button', { name: 'Test' }).click()
|
||||||
await expect(page.getByRole('status')).toContainText('Connection ok')
|
await expect(page.getByText(/Connection ok/)).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'New strategy' }).click()
|
await page.getByRole('button', { name: 'New strategy' }).click()
|
||||||
await page.getByLabel('Strategy name').fill('Gitea database')
|
await page.getByLabel('Strategy name', { exact: true }).fill('Gitea database')
|
||||||
await page.getByLabel('Source type').selectOption('postgres_dump')
|
await page.getByLabel('Source type', { exact: true }).selectOption('postgres_dump')
|
||||||
await page.getByLabel('Namespace').fill('gitea')
|
await page.getByLabel('Namespace', { exact: true }).fill('gitea')
|
||||||
await page.getByLabel('Pod').fill('gitea-postgresql-0')
|
await page.getByLabel('Pod', { exact: true }).fill('gitea-postgresql-0')
|
||||||
await page.getByLabel('Schedule').fill('0 0 2 * * *')
|
await page.getByLabel('Schedule', { exact: true }).fill('0 0 2 * * *')
|
||||||
await page.getByLabel('Target').selectOption({ label: name })
|
await page.getByLabel('Target', { exact: true }).selectOption({ label: name })
|
||||||
await page.getByLabel('Keep last').fill('3')
|
await page.getByLabel('Keep last', { exact: true }).fill('3')
|
||||||
await page.getByRole('button', { name: 'Save strategy' }).click()
|
await page.getByRole('button', { name: 'Save strategy' }).click()
|
||||||
const row = page.getByRole('row', { name: /Gitea database/ })
|
const row = page.getByRole('row', { name: /Gitea database/ })
|
||||||
await expect(row).toBeVisible()
|
await expect(row).toBeVisible()
|
||||||
|
|||||||
@ -60,4 +60,5 @@ export const api = {
|
|||||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||||
|
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||||
}
|
}
|
||||||
|
|||||||
183
frontend/src/components/StrategyForm.vue
Normal file
183
frontend/src/components/StrategyForm.vue
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { BackupSource, BackupStrategyPayload, BackupStrategyView } from '../api/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
targets: { id: string; name: string }[]
|
||||||
|
current?: BackupStrategyView | null
|
||||||
|
busy?: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ submit: [payload: BackupStrategyPayload]; cancel: [] }>()
|
||||||
|
|
||||||
|
type SourceType = BackupSource['type']
|
||||||
|
const cur = props.current
|
||||||
|
const name = ref(cur?.name ?? '')
|
||||||
|
const sourceType = ref<SourceType>(cur?.source.type ?? 'volume_claim')
|
||||||
|
const namespace = ref(cur && 'namespace' in cur.source ? cur.source.namespace : 'gitea')
|
||||||
|
const pvc = ref(cur?.source.type === 'volume_claim' ? cur.source.pvc : '')
|
||||||
|
const pod = ref(cur?.source.type === 'postgres_dump' ? cur.source.pod : '')
|
||||||
|
const path = ref(cur?.source.type === 'host_path' ? cur.source.path : '')
|
||||||
|
const schedule = ref(cur?.schedule ?? '0 0 2 * * *')
|
||||||
|
const targetId = ref(cur?.target_id ?? props.targets[0]?.id ?? '')
|
||||||
|
const retention = ref<string>(cur?.retention.toString() ?? '7')
|
||||||
|
const passphrase = ref('')
|
||||||
|
const enabled = ref(cur?.enabled ?? true)
|
||||||
|
|
||||||
|
function source(): BackupSource {
|
||||||
|
switch (sourceType.value) {
|
||||||
|
case 'volume_claim':
|
||||||
|
return { type: 'volume_claim', namespace: namespace.value, pvc: pvc.value }
|
||||||
|
case 'postgres_dump':
|
||||||
|
return { type: 'postgres_dump', namespace: namespace.value, pod: pod.value }
|
||||||
|
case 'kubernetes_manifests':
|
||||||
|
return { type: 'kubernetes_manifests', namespace: namespace.value }
|
||||||
|
case 'host_path':
|
||||||
|
return { type: 'host_path', path: path.value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
// create: empty = no encryption; edit: empty keeps the stored passphrase
|
||||||
|
const pass = passphrase.value === '' ? (cur?.encrypted ? '' : null) : passphrase.value
|
||||||
|
emit('submit', {
|
||||||
|
name: name.value,
|
||||||
|
source: source(),
|
||||||
|
schedule: schedule.value,
|
||||||
|
target_id: targetId.value,
|
||||||
|
retention: Number(retention.value),
|
||||||
|
passphrase: pass,
|
||||||
|
enabled: enabled.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const input = 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="grid gap-4" @submit.prevent="submit">
|
||||||
|
<div>
|
||||||
|
<label for="s-name" class="block text-sm font-medium">Strategy name</label>
|
||||||
|
<input id="s-name" v-model="name" name="name" required :class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="s-type" class="block text-sm font-medium">Source type</label>
|
||||||
|
<select id="s-type" v-model="sourceType" name="source_type" :class="input">
|
||||||
|
<option value="volume_claim">Persistent volume (e.g. Gitea data)</option>
|
||||||
|
<option value="postgres_dump">PostgreSQL dump (pg_dumpall in pod)</option>
|
||||||
|
<option value="kubernetes_manifests">Kubernetes manifests of a namespace</option>
|
||||||
|
<option value="host_path">Directory on the host</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="sourceType !== 'host_path'" class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="s-ns" class="block text-sm font-medium">Namespace</label>
|
||||||
|
<input id="s-ns" v-model="namespace" name="namespace" required :class="input" />
|
||||||
|
</div>
|
||||||
|
<div v-if="sourceType === 'volume_claim'">
|
||||||
|
<label for="s-pvc" class="block text-sm font-medium">PVC</label>
|
||||||
|
<input
|
||||||
|
id="s-pvc"
|
||||||
|
v-model="pvc"
|
||||||
|
name="pvc"
|
||||||
|
required
|
||||||
|
placeholder="gitea-shared-storage"
|
||||||
|
:class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="sourceType === 'postgres_dump'">
|
||||||
|
<label for="s-pod" class="block text-sm font-medium">Pod</label>
|
||||||
|
<input
|
||||||
|
id="s-pod"
|
||||||
|
v-model="pod"
|
||||||
|
name="pod"
|
||||||
|
required
|
||||||
|
placeholder="gitea-postgresql-0"
|
||||||
|
:class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<label for="s-path" class="block text-sm font-medium">Host path</label>
|
||||||
|
<input
|
||||||
|
id="s-path"
|
||||||
|
v-model="path"
|
||||||
|
name="path"
|
||||||
|
required
|
||||||
|
placeholder="/opt/monitoring/data"
|
||||||
|
:class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="s-cron" class="block text-sm font-medium">Schedule</label>
|
||||||
|
<input
|
||||||
|
id="s-cron"
|
||||||
|
v-model="schedule"
|
||||||
|
name="schedule"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">sec min hour day month weekday</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="s-target" class="block text-sm font-medium">Target</label>
|
||||||
|
<select id="s-target" v-model="targetId" name="target_id" required :class="input">
|
||||||
|
<option v-for="t in targets" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="s-keep" class="block text-sm font-medium">Keep last</label>
|
||||||
|
<input
|
||||||
|
id="s-keep"
|
||||||
|
v-model="retention"
|
||||||
|
name="retention"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="365"
|
||||||
|
required
|
||||||
|
:class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="s-pass" class="block text-sm font-medium">Encryption passphrase (optional)</label>
|
||||||
|
<input
|
||||||
|
id="s-pass"
|
||||||
|
v-model="passphrase"
|
||||||
|
name="passphrase"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
minlength="12"
|
||||||
|
:class="input"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
<template v-if="cur?.encrypted"
|
||||||
|
>Archives are encrypted, leave empty to keep the passphrase.</template
|
||||||
|
>
|
||||||
|
<template v-else
|
||||||
|
>AES-256 via openssl; restore with
|
||||||
|
<code>openssl enc -d -aes-256-cbc -pbkdf2</code>.</template
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="enabled" name="enabled" type="checkbox" />
|
||||||
|
Enabled (runs on schedule)
|
||||||
|
</label>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
|
||||||
|
@click="emit('cancel')"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="busy"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save strategy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
123
frontend/src/components/TargetForm.vue
Normal file
123
frontend/src/components/TargetForm.vue
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { BackupTargetPayload, BackupTargetView, StorageKind } from '../api/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ current?: BackupTargetView | null; busy?: boolean }>()
|
||||||
|
const emit = defineEmits<{ submit: [payload: BackupTargetPayload]; cancel: [] }>()
|
||||||
|
|
||||||
|
const name = ref(props.current?.name ?? '')
|
||||||
|
const kind = ref<StorageKind>(props.current?.kind ?? 'smb')
|
||||||
|
const host = ref(props.current?.host ?? '')
|
||||||
|
const port = ref<string>(props.current?.port?.toString() ?? '')
|
||||||
|
const share = ref(props.current?.share ?? '')
|
||||||
|
const path = ref(props.current?.path ?? '')
|
||||||
|
const username = ref(props.current?.username ?? '')
|
||||||
|
const password = ref('')
|
||||||
|
const tls = ref(props.current?.tls ?? false)
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
emit('submit', {
|
||||||
|
name: name.value,
|
||||||
|
kind: kind.value,
|
||||||
|
host: host.value,
|
||||||
|
port: port.value ? Number(port.value) : null,
|
||||||
|
share: kind.value === 'smb' ? share.value : '',
|
||||||
|
path: path.value,
|
||||||
|
username: username.value,
|
||||||
|
password: password.value,
|
||||||
|
tls: kind.value === 'ftp' ? tls.value : false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const input = 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="grid gap-4" @submit.prevent="submit">
|
||||||
|
<div>
|
||||||
|
<label for="t-name" class="block text-sm font-medium">Name</label>
|
||||||
|
<input id="t-name" v-model="name" name="name" required :class="input" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="t-kind" class="block text-sm font-medium">Type</label>
|
||||||
|
<select id="t-kind" v-model="kind" name="kind" :class="input">
|
||||||
|
<option value="smb">SMB / CIFS</option>
|
||||||
|
<option value="ftp">FTP / FTPS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label for="t-host" class="block text-sm font-medium">Host</label>
|
||||||
|
<input id="t-host" v-model="host" name="host" required :class="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="kind === 'smb'">
|
||||||
|
<label for="t-share" class="block text-sm font-medium">Share</label>
|
||||||
|
<input id="t-share" v-model="share" name="share" required :class="input" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="t-port" class="block text-sm font-medium">Port</label>
|
||||||
|
<input
|
||||||
|
id="t-port"
|
||||||
|
v-model="port"
|
||||||
|
name="port"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
placeholder="21"
|
||||||
|
:class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-end gap-2 pb-2 text-sm">
|
||||||
|
<input v-model="tls" name="tls" type="checkbox" />
|
||||||
|
Use TLS (FTPS)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="t-path" class="block text-sm font-medium">Directory</label>
|
||||||
|
<input
|
||||||
|
id="t-path"
|
||||||
|
v-model="path"
|
||||||
|
name="path"
|
||||||
|
placeholder="backups/softvisor"
|
||||||
|
:class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="t-user" class="block text-sm font-medium">Username</label>
|
||||||
|
<input id="t-user" v-model="username" name="username" :class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="t-pass" class="block text-sm font-medium">Password</label>
|
||||||
|
<input
|
||||||
|
id="t-pass"
|
||||||
|
v-model="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:class="input"
|
||||||
|
/>
|
||||||
|
<p v-if="current?.password_set" class="mt-1 text-xs text-gray-500">
|
||||||
|
A password is stored, leave empty to keep it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
|
||||||
|
@click="emit('cancel')"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="busy"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save target
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
365
frontend/src/pages/BackupsPage.vue
Normal file
365
frontend/src/pages/BackupsPage.vue
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { api, ApiError } from '../api/client'
|
||||||
|
import type {
|
||||||
|
BackupRecord,
|
||||||
|
BackupStrategyPayload,
|
||||||
|
BackupTargetPayload,
|
||||||
|
BackupTargetView,
|
||||||
|
JobRun,
|
||||||
|
StrategyStatus,
|
||||||
|
} from '../api/types'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useToastStore } from '../stores/toast'
|
||||||
|
import TargetForm from '../components/TargetForm.vue'
|
||||||
|
import StrategyForm from '../components/StrategyForm.vue'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
const targets = ref<BackupTargetView[]>([])
|
||||||
|
const strategies = ref<StrategyStatus[]>([])
|
||||||
|
const dialog = ref<
|
||||||
|
| { kind: 'target'; current: BackupTargetView | null }
|
||||||
|
| { kind: 'strategy'; current: StrategyStatus | null }
|
||||||
|
| { kind: 'history'; strategy: StrategyStatus; records: BackupRecord[] }
|
||||||
|
| null
|
||||||
|
>(null)
|
||||||
|
const busy = ref(false)
|
||||||
|
const running = ref<Record<string, JobRun>>({})
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
const fail = (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Request failed')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
;[targets.value, strategies.value] = await Promise.all([
|
||||||
|
api.get<BackupTargetView[]>('/api/backups/targets'),
|
||||||
|
api.get<StrategyStatus[]>('/api/backups/strategies'),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
onMounted(() => load().catch(fail))
|
||||||
|
onUnmounted(() => clearInterval(timer))
|
||||||
|
|
||||||
|
async function saveTarget(payload: BackupTargetPayload) {
|
||||||
|
if (!dialog.value || dialog.value.kind !== 'target') return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
if (dialog.value.current)
|
||||||
|
await api.put(`/api/backups/targets/${dialog.value.current.id}`, payload)
|
||||||
|
else await api.post('/api/backups/targets', payload)
|
||||||
|
toast.success('Target saved')
|
||||||
|
dialog.value = null
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
fail(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testTarget(t: BackupTargetView) {
|
||||||
|
try {
|
||||||
|
await api.post(`/api/backups/targets/${t.id}/test`, {})
|
||||||
|
toast.success(`Connection ok: ${t.name}`)
|
||||||
|
} catch (e) {
|
||||||
|
fail(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTarget(t: BackupTargetView) {
|
||||||
|
if (!confirm(`Delete target ${t.name}?`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/backups/targets/${t.id}`)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
fail(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStrategy(payload: BackupStrategyPayload) {
|
||||||
|
if (!dialog.value || dialog.value.kind !== 'strategy') return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
if (dialog.value.current)
|
||||||
|
await api.put(`/api/backups/strategies/${dialog.value.current.id}`, payload)
|
||||||
|
else await api.post('/api/backups/strategies', payload)
|
||||||
|
toast.success('Strategy saved')
|
||||||
|
dialog.value = null
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
fail(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStrategy(s: StrategyStatus) {
|
||||||
|
if (!confirm(`Delete strategy ${s.name}? Existing backups on the target are kept.`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/backups/strategies/${s.id}`)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
fail(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNow(s: StrategyStatus) {
|
||||||
|
try {
|
||||||
|
const run = await api.post<JobRun>(`/api/backups/strategies/${s.id}/run`, {})
|
||||||
|
running.value[s.id] = run
|
||||||
|
toast.success(`Backup '${s.name}' started`)
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = setInterval(async () => {
|
||||||
|
let active = false
|
||||||
|
for (const [sid, r] of Object.entries(running.value)) {
|
||||||
|
if (r.status !== 'running') continue
|
||||||
|
const fresh = await api.get<JobRun>(`/api/jobs/${r.id}`)
|
||||||
|
running.value[sid] = fresh
|
||||||
|
if (fresh.status === 'running') active = true
|
||||||
|
else {
|
||||||
|
if (fresh.status === 'success') toast.success('Backup finished')
|
||||||
|
else toast.error('Backup failed, see Jobs for the log')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!active) clearInterval(timer)
|
||||||
|
}, 2000)
|
||||||
|
} catch (e) {
|
||||||
|
fail(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function history(s: StrategyStatus) {
|
||||||
|
try {
|
||||||
|
dialog.value = {
|
||||||
|
kind: 'history',
|
||||||
|
strategy: s,
|
||||||
|
records: await api.get<BackupRecord[]>(`/api/backups/strategies/${s.id}/records`),
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
fail(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabel = (s: StrategyStatus) => {
|
||||||
|
const src = s.source
|
||||||
|
switch (src.type) {
|
||||||
|
case 'volume_claim':
|
||||||
|
return `PVC ${src.namespace}/${src.pvc}`
|
||||||
|
case 'postgres_dump':
|
||||||
|
return `pg_dumpall ${src.namespace}/${src.pod}`
|
||||||
|
case 'kubernetes_manifests':
|
||||||
|
return `manifests ${src.namespace}`
|
||||||
|
case 'host_path':
|
||||||
|
return `host ${src.path}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const size = (b: number) =>
|
||||||
|
b > 1e9
|
||||||
|
? `${(b / 1e9).toFixed(1)} GB`
|
||||||
|
: b > 1e6
|
||||||
|
? `${(b / 1e6).toFixed(1)} MB`
|
||||||
|
: `${Math.ceil(b / 1e3)} kB`
|
||||||
|
const fmt = (d: string) => new Date(d).toLocaleString()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="text-2xl font-semibold">Backups</h1>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-medium">Targets</h2>
|
||||||
|
<button
|
||||||
|
v-if="auth.isAdmin"
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
|
||||||
|
@click="dialog = { kind: 'target', current: null }"
|
||||||
|
>
|
||||||
|
New target
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table class="mt-3 w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-gray-200 text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2">Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="t in targets" :key="t.id" class="border-b border-gray-100">
|
||||||
|
<td class="py-1.5 font-medium">{{ t.name }}</td>
|
||||||
|
<td class="uppercase">{{ t.kind }}{{ t.tls ? 'S' : '' }}</td>
|
||||||
|
<td class="font-mono text-xs">
|
||||||
|
{{
|
||||||
|
t.kind === 'smb'
|
||||||
|
? `//${t.host}/${t.share}/${t.path}`
|
||||||
|
: `ftp://${t.host}${t.port ? ':' + t.port : ''}/${t.path}`
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td>{{ t.username || '–' }}</td>
|
||||||
|
<td class="space-x-3 whitespace-nowrap text-right">
|
||||||
|
<template v-if="auth.isAdmin">
|
||||||
|
<button class="text-blue-600 hover:underline" @click="testTarget(t)">Test</button>
|
||||||
|
<button
|
||||||
|
class="text-blue-600 hover:underline"
|
||||||
|
@click="dialog = { kind: 'target', current: t }"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button class="text-red-600 hover:underline" @click="deleteTarget(t)">Delete</button>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="targets.length === 0">
|
||||||
|
<td colspan="5" class="py-4 text-center text-gray-500">
|
||||||
|
No targets yet. Add an SMB share or FTP server first.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-10">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-medium">Strategies</h2>
|
||||||
|
<button
|
||||||
|
v-if="auth.isAdmin"
|
||||||
|
:disabled="targets.length === 0"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
@click="dialog = { kind: 'strategy', current: null }"
|
||||||
|
>
|
||||||
|
New strategy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table class="mt-3 w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-gray-200 text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2">Name</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Schedule</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Keep</th>
|
||||||
|
<th>Last backup</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="s in strategies"
|
||||||
|
:key="s.id"
|
||||||
|
class="border-b border-gray-100"
|
||||||
|
:class="s.enabled ? '' : 'text-gray-400'"
|
||||||
|
>
|
||||||
|
<td class="py-1.5 font-medium">
|
||||||
|
{{ s.name }}
|
||||||
|
<span v-if="s.encrypted" class="ml-1 rounded bg-gray-100 px-1 text-xs text-gray-600"
|
||||||
|
>encrypted</span
|
||||||
|
>
|
||||||
|
<span v-if="!s.enabled" class="ml-1 rounded bg-gray-100 px-1 text-xs">disabled</span>
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-xs">{{ sourceLabel(s) }}</td>
|
||||||
|
<td class="font-mono text-xs">{{ s.schedule }}</td>
|
||||||
|
<td>{{ s.target_name }}</td>
|
||||||
|
<td>{{ s.retention }}</td>
|
||||||
|
<td class="text-xs">
|
||||||
|
<template v-if="running[s.id]?.status === 'running'"
|
||||||
|
><span class="text-blue-600">running…</span></template
|
||||||
|
>
|
||||||
|
<template v-else-if="s.last_backup">
|
||||||
|
<div class="font-mono">{{ s.last_backup.filename }}</div>
|
||||||
|
<div class="text-gray-500">
|
||||||
|
{{ fmt(s.last_backup.created_at) }} · {{ size(s.last_backup.size_bytes) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>never</template>
|
||||||
|
</td>
|
||||||
|
<td class="space-x-3 whitespace-nowrap text-right">
|
||||||
|
<button class="text-blue-600 hover:underline" @click="history(s)">History</button>
|
||||||
|
<template v-if="auth.isAdmin">
|
||||||
|
<button
|
||||||
|
class="text-blue-600 hover:underline"
|
||||||
|
:disabled="running[s.id]?.status === 'running'"
|
||||||
|
@click="runNow(s)"
|
||||||
|
>
|
||||||
|
Run now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-blue-600 hover:underline"
|
||||||
|
@click="dialog = { kind: 'strategy', current: s }"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button class="text-red-600 hover:underline" @click="deleteStrategy(s)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="strategies.length === 0">
|
||||||
|
<td colspan="7" class="py-4 text-center text-gray-500">No strategies yet.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="dialog"
|
||||||
|
class="fixed inset-0 flex items-center justify-center overflow-y-auto bg-black/30"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-xl rounded-lg bg-white p-6 shadow-lg" role="dialog">
|
||||||
|
<template v-if="dialog.kind === 'target'">
|
||||||
|
<h2 class="mb-4 text-lg font-semibold">
|
||||||
|
{{ dialog.current ? 'Edit target' : 'New target' }}
|
||||||
|
</h2>
|
||||||
|
<TargetForm
|
||||||
|
:key="dialog.current?.id ?? 'new'"
|
||||||
|
:current="dialog.current"
|
||||||
|
:busy="busy"
|
||||||
|
@submit="saveTarget"
|
||||||
|
@cancel="dialog = null"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="dialog.kind === 'strategy'">
|
||||||
|
<h2 class="mb-4 text-lg font-semibold">
|
||||||
|
{{ dialog.current ? 'Edit strategy' : 'New strategy' }}
|
||||||
|
</h2>
|
||||||
|
<StrategyForm
|
||||||
|
:key="dialog.current?.id ?? 'new'"
|
||||||
|
:targets="targets"
|
||||||
|
:current="dialog.current"
|
||||||
|
:busy="busy"
|
||||||
|
@submit="saveStrategy"
|
||||||
|
@cancel="dialog = null"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold">Backups of {{ dialog.strategy.name }}</h2>
|
||||||
|
<button class="text-blue-600 hover:underline" @click="dialog = null">Close</button>
|
||||||
|
</div>
|
||||||
|
<table data-testid="backup-records" class="mt-4 w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-gray-200 text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2">File</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>SHA-256</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in dialog.records" :key="r.id" class="border-b border-gray-100">
|
||||||
|
<td class="py-1 font-mono text-xs">{{ r.filename }}</td>
|
||||||
|
<td class="text-xs">{{ fmt(r.created_at) }}</td>
|
||||||
|
<td class="text-xs">{{ size(r.size_bytes) }}</td>
|
||||||
|
<td class="font-mono text-xs" :title="r.sha256">{{ r.sha256.slice(0, 12) }}…</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="dialog.records.length === 0">
|
||||||
|
<td colspan="4" class="py-3 text-center text-gray-500">No backups yet.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -1,8 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineProps<{ title: string }>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<h1 class="text-2xl font-semibold">{{ title }}</h1>
|
|
||||||
<p class="mt-2 text-gray-600">Not implemented yet.</p>
|
|
||||||
</template>
|
|
||||||
@ -5,12 +5,12 @@ import AppShell from './components/AppShell.vue'
|
|||||||
import LoginPage from './pages/LoginPage.vue'
|
import LoginPage from './pages/LoginPage.vue'
|
||||||
import DashboardPage from './pages/DashboardPage.vue'
|
import DashboardPage from './pages/DashboardPage.vue'
|
||||||
import UsersPage from './pages/UsersPage.vue'
|
import UsersPage from './pages/UsersPage.vue'
|
||||||
import PlaceholderPage from './pages/PlaceholderPage.vue'
|
|
||||||
import SettingsPage from './pages/SettingsPage.vue'
|
import SettingsPage from './pages/SettingsPage.vue'
|
||||||
import JobsPage from './pages/JobsPage.vue'
|
import JobsPage from './pages/JobsPage.vue'
|
||||||
import UpdatesPage from './pages/UpdatesPage.vue'
|
import UpdatesPage from './pages/UpdatesPage.vue'
|
||||||
import ClusterPage from './pages/ClusterPage.vue'
|
import ClusterPage from './pages/ClusterPage.vue'
|
||||||
import VulnerabilitiesPage from './pages/VulnerabilitiesPage.vue'
|
import VulnerabilitiesPage from './pages/VulnerabilitiesPage.vue'
|
||||||
|
import BackupsPage from './pages/BackupsPage.vue'
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@ -24,7 +24,7 @@ export const router = createRouter({
|
|||||||
{ path: 'updates', component: UpdatesPage },
|
{ path: 'updates', component: UpdatesPage },
|
||||||
{ path: 'cluster', component: ClusterPage },
|
{ path: 'cluster', component: ClusterPage },
|
||||||
{ path: 'vulnerabilities', component: VulnerabilitiesPage },
|
{ path: 'vulnerabilities', component: VulnerabilitiesPage },
|
||||||
{ path: 'backups', component: PlaceholderPage, props: { title: 'Backups' } },
|
{ path: 'backups', component: BackupsPage },
|
||||||
{ path: 'users', component: UsersPage, meta: { admin: true } },
|
{ path: 'users', component: UsersPage, meta: { admin: true } },
|
||||||
{ path: 'jobs', component: JobsPage },
|
{ path: 'jobs', component: JobsPage },
|
||||||
{ path: 'settings', component: SettingsPage },
|
{ path: 'settings', component: SettingsPage },
|
||||||
|
|||||||
Reference in New Issue
Block a user