use axum::extract::State;
use axum::{Extension, Json};
use serde_json::{Value, json};
use crate::errors::OrionError;
use crate::server::admin_auth::AdminPrincipal;
use crate::server::routes::openapi::{BackupFile, BackupListItem, DataEnvelope};
use crate::server::routes::response_helpers::data_response;
use crate::server::state::AppState;
use super::audit_log;
fn reject_in_cluster_mode(state: &AppState) -> Result<(), OrionError> {
if state.cluster.enabled {
return Err(OrionError::validation(
"Filesystem backups are disabled in cluster mode — the file would land on \
one arbitrary node. Use your managed database's snapshot/PITR tooling \
(e.g. pg_dump, RDS/Cloud SQL backups) instead; see the availability docs."
.to_string(),
));
}
Ok(())
}
#[utoipa::path(
post,
path = "/api/v1/admin/backups",
tag = "Backups",
responses(
(status = 200, description = "Backup created (SQLite only — VACUUM INTO a timestamped file)", body = DataEnvelope<BackupFile>),
(status = 400, description = "Backup unavailable (non-SQLite backend, or cluster mode — use managed-DB snapshots/PITR)"),
)
)]
#[tracing::instrument(skip(state, principal))]
pub(crate) async fn create_backup(
State(state): State<AppState>,
principal: Option<Extension<AdminPrincipal>>,
) -> Result<Json<Value>, OrionError> {
reject_in_cluster_mode(&state)?;
let backup_dir = state.config.storage.backup_dir.clone();
let dir = backup_dir.clone();
tokio::task::spawn_blocking(move || std::fs::create_dir_all(&dir))
.await
.map_err(|e| OrionError::internal(format!("spawn_blocking failed: {e}")))?
.map_err(|e| OrionError::Internal {
context: format!("Failed to create backup directory '{backup_dir}'"),
source: Some(Box::new(e)),
})?;
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let filename = format!("orion_backup_{timestamp}.db");
let backup_path = std::path::Path::new(&backup_dir).join(&filename);
let backup_path_str = backup_path.to_string_lossy().to_string();
let backed_up = state
.backup_sqlite_into(&backup_path_str)
.await
.map_err(|e| OrionError::Internal {
context: "Failed to create database backup".to_string(),
source: Some(Box::new(e)),
})?;
if !backed_up {
return Err(OrionError::validation(
"Database backup via VACUUM INTO is only supported for SQLite".to_string(),
));
}
let meta_path = backup_path.clone();
let metadata = tokio::task::spawn_blocking(move || std::fs::metadata(&meta_path))
.await
.map_err(|e| OrionError::internal(format!("spawn_blocking failed: {e}")))?
.map_err(|e| OrionError::Internal {
context: "Failed to read backup file metadata".to_string(),
source: Some(Box::new(e)),
})?;
if let Some(retain) = state.config.storage.backup_retention_count {
let dir = backup_dir.clone();
match tokio::task::spawn_blocking(move || prune_old_backups(&dir, retain as usize)).await {
Ok(pruned) if !pruned.is_empty() => {
tracing::info!(
pruned = ?pruned,
retained = retain,
"Backup retention: pruned old backup files"
);
}
Ok(_) => {}
Err(e) => {
tracing::warn!(error = %e, "Backup retention: prune task failed");
}
}
}
audit_log(
&state.audit_queue,
&principal,
"create",
"backup",
&filename,
);
Ok(data_response(json!({
"filename": filename,
"path": backup_path_str,
"size_bytes": metadata.len(),
"created_at": chrono::Utc::now().to_rfc3339(),
})))
}
fn is_backup_file(path: &std::path::Path) -> bool {
path.extension().is_some_and(|ext| ext == "db")
&& path
.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with("orion_backup_"))
}
fn prune_old_backups(backup_dir: &str, retain: usize) -> Vec<String> {
let dir = match std::fs::read_dir(backup_dir) {
Ok(d) => d,
Err(e) => {
tracing::warn!(
dir = %backup_dir,
error = %e,
"Backup retention: cannot read backup directory; skipping prune"
);
return Vec::new();
}
};
let mut names: Vec<String> = dir
.flatten()
.filter_map(|entry| {
let path = entry.path();
is_backup_file(&path).then(|| entry.file_name().to_string_lossy().into_owned())
})
.collect();
if names.len() <= retain {
return Vec::new();
}
names.sort();
let cutoff = names.len() - retain;
let mut pruned = Vec::new();
for name in names.drain(..cutoff) {
let path = std::path::Path::new(backup_dir).join(&name);
match std::fs::remove_file(&path) {
Ok(()) => pruned.push(name),
Err(e) => {
tracing::warn!(
file = %name,
error = %e,
"Backup retention: failed to prune old backup file"
);
}
}
}
pruned
}
#[utoipa::path(
get,
path = "/api/v1/admin/backups",
tag = "Backups",
responses(
(status = 200, description = "List of backup files in the configured backup directory", body = DataEnvelope<Vec<BackupListItem>>),
(status = 400, description = "Unavailable in cluster mode — backups are node-local files"),
)
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn list_backups(State(state): State<AppState>) -> Result<Json<Value>, OrionError> {
reject_in_cluster_mode(&state)?;
let backup_dir = state.config.storage.backup_dir.clone();
let backups = tokio::task::spawn_blocking(move || -> Result<Vec<Value>, OrionError> {
let dir = match std::fs::read_dir(&backup_dir) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(e) => {
return Err(OrionError::Internal {
context: format!("Failed to read backup directory '{backup_dir}'"),
source: Some(Box::new(e)),
});
}
};
let mut backups = Vec::new();
for entry in dir.flatten() {
let path = entry.path();
if is_backup_file(&path)
&& let Ok(meta) = entry.metadata()
{
let modified = meta
.modified()
.ok()
.map(|t| {
let dt: chrono::DateTime<chrono::Utc> = t.into();
dt.to_rfc3339()
})
.unwrap_or_default();
backups.push(json!({
"filename": path.file_name().unwrap_or_default().to_string_lossy(),
"size_bytes": meta.len(),
"modified_at": modified,
}));
}
}
backups.sort_by(|a, b| b["filename"].as_str().cmp(&a["filename"].as_str()));
Ok(backups)
})
.await
.map_err(|e| OrionError::internal(format!("spawn_blocking failed: {e}")))??;
Ok(data_response(backups))
}
#[cfg(test)]
mod tests {
use super::prune_old_backups;
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"orion_prune_test_{label}_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&path).expect("test dir");
Self(path)
}
fn touch(&self, name: &str) {
std::fs::write(self.0.join(name), b"x").expect("test file");
}
fn names(&self) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(&self.0)
.expect("test dir")
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
fn path(&self) -> &str {
self.0.to_str().expect("utf-8 temp path")
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn test_prunes_oldest_backups_beyond_retention() {
let dir = TempDir::new("oldest");
dir.touch("orion_backup_20240101_000000.db");
dir.touch("orion_backup_20240102_000000.db");
dir.touch("orion_backup_20240103_000000.db");
dir.touch("orion_backup_20240104_000000.db");
let mut pruned = prune_old_backups(dir.path(), 2);
pruned.sort();
assert_eq!(
pruned,
vec![
"orion_backup_20240101_000000.db",
"orion_backup_20240102_000000.db"
],
"the two oldest must be pruned and reported"
);
assert_eq!(
dir.names(),
vec![
"orion_backup_20240103_000000.db",
"orion_backup_20240104_000000.db"
],
"the two newest must survive"
);
}
#[test]
fn test_prune_ignores_non_backup_files() {
let dir = TempDir::new("foreign");
dir.touch("orion.db");
dir.touch("notes.txt");
dir.touch("orion_backup_20240101_000000.db");
dir.touch("orion_backup_20240102_000000.db");
let pruned = prune_old_backups(dir.path(), 1);
assert_eq!(pruned, vec!["orion_backup_20240101_000000.db"]);
assert_eq!(
dir.names(),
vec!["notes.txt", "orion.db", "orion_backup_20240102_000000.db"],
"unrelated files must be untouched"
);
}
#[test]
fn test_prune_is_a_noop_at_or_under_retention() {
let dir = TempDir::new("noop");
dir.touch("orion_backup_20240101_000000.db");
assert!(prune_old_backups(dir.path(), 1).is_empty());
assert!(prune_old_backups(dir.path(), 5).is_empty());
assert_eq!(dir.names(), vec!["orion_backup_20240101_000000.db"]);
assert!(prune_old_backups("/nonexistent/orion-prune-test", 1).is_empty());
}
}