Nytt Rust CLI-verktøy som erstatter scripts/backup-pg.sh: - pg_dump -Fc via docker exec (konsistent snapshot) - CAS-manifest: liste over alle filer med hash og størrelse - Metadata-snapshot (JSON) med tidsstempel, modus, statistikk - --full / --incremental / --payload-json for jobbkø - Rotasjon av gamle dumper (30 dager, kun ved --full) Output: strukturert JSON med backup-sti og detaljer.
528 lines
17 KiB
Rust
528 lines
17 KiB
Rust
// synops-backup — PG-dump + CAS-filiste + metadata-snapshot.
|
|
//
|
|
// Erstatter scripts/backup-pg.sh (cron-script fra oppgave 12.2).
|
|
// Gjør tre ting:
|
|
// 1. pg_dump -Fc via docker exec (konsistent snapshot)
|
|
// 2. Lister alle CAS-filer med størrelse
|
|
// 3. Skriver metadata-snapshot (JSON) med tidsstempel, modus, statistikk
|
|
//
|
|
// Bruk:
|
|
// synops-backup --full # Full backup
|
|
// synops-backup --incremental # Inkrementell (kun endringer siden forrige)
|
|
// synops-backup --payload-json '{"mode":"full"}' # Jobbkø-dispatch
|
|
//
|
|
// Output: backup-sti til stdout (JSON).
|
|
// Feil: stderr + exit code != 0.
|
|
//
|
|
// Ref: docs/infra/backup.md, docs/retninger/unix_filosofi.md
|
|
|
|
use chrono::Utc;
|
|
use clap::Parser;
|
|
use serde::Serialize;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Backup-modus.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
enum Mode {
|
|
Full,
|
|
Incremental,
|
|
}
|
|
|
|
/// PG-dump + CAS-filiste + metadata-snapshot.
|
|
#[derive(Parser)]
|
|
#[command(name = "synops-backup", about = "Backup: PG-dump, CAS-filiste og metadata-snapshot")]
|
|
struct Cli {
|
|
/// Full backup (PG-dump + CAS-liste + metadata)
|
|
#[arg(long, conflicts_with = "incremental")]
|
|
full: bool,
|
|
|
|
/// Inkrementell backup (kun CAS-filer nyere enn forrige backup)
|
|
#[arg(long, conflicts_with = "full")]
|
|
incremental: bool,
|
|
|
|
/// Backup-katalog (default: /srv/synops/backup)
|
|
#[arg(long, default_value = "/srv/synops/backup")]
|
|
backup_dir: String,
|
|
|
|
/// PG Docker-container (default: sidelinja-postgres-1)
|
|
#[arg(long, default_value = "sidelinja-postgres-1")]
|
|
pg_container: String,
|
|
|
|
/// Database-bruker (default: sidelinja)
|
|
#[arg(long, default_value = "sidelinja")]
|
|
db_user: String,
|
|
|
|
/// Databasenavn (default: sidelinja)
|
|
#[arg(long, default_value = "sidelinja")]
|
|
db_name: String,
|
|
|
|
/// Antall dager å beholde gamle backuper (default: 30)
|
|
#[arg(long, default_value = "30")]
|
|
retain_days: u32,
|
|
|
|
/// Payload fra jobbkø (JSON). Overstyrer andre argumenter.
|
|
#[arg(long)]
|
|
payload_json: Option<String>,
|
|
}
|
|
|
|
/// Resultat fra backup.
|
|
#[derive(Serialize)]
|
|
struct BackupResult {
|
|
ok: bool,
|
|
mode: String,
|
|
timestamp: String,
|
|
backup_dir: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pg_dump: Option<PgDumpResult>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
cas_manifest: Option<CasManifestResult>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
metadata_file: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
rotation: Option<RotationResult>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
error: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PgDumpResult {
|
|
path: String,
|
|
size_bytes: u64,
|
|
size_human: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CasManifestResult {
|
|
path: String,
|
|
file_count: u64,
|
|
total_size_bytes: u64,
|
|
total_size_human: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct RotationResult {
|
|
deleted_count: u32,
|
|
retain_days: u32,
|
|
}
|
|
|
|
/// Metadata-snapshot som skrives til backup-katalogen.
|
|
#[derive(Serialize)]
|
|
struct MetadataSnapshot {
|
|
timestamp: String,
|
|
mode: String,
|
|
pg_dump_size_bytes: Option<u64>,
|
|
cas_file_count: Option<u64>,
|
|
cas_total_size_bytes: Option<u64>,
|
|
hostname: String,
|
|
db_name: String,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
synops_common::logging::init("synops_backup");
|
|
|
|
let cli = Cli::parse();
|
|
|
|
// Resolve mode fra args eller payload-json
|
|
let (mode, backup_dir, pg_container, db_user, db_name, retain_days) =
|
|
if let Some(ref json_str) = cli.payload_json {
|
|
let payload: serde_json::Value = serde_json::from_str(json_str).unwrap_or_else(|e| {
|
|
eprintln!("Ugyldig --payload-json: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
let mode = match payload["mode"].as_str().unwrap_or("full") {
|
|
"incremental" => Mode::Incremental,
|
|
_ => Mode::Full,
|
|
};
|
|
let backup_dir = payload["backup_dir"]
|
|
.as_str()
|
|
.unwrap_or("/srv/synops/backup")
|
|
.to_string();
|
|
let pg_container = payload["pg_container"]
|
|
.as_str()
|
|
.unwrap_or("sidelinja-postgres-1")
|
|
.to_string();
|
|
let db_user = payload["db_user"]
|
|
.as_str()
|
|
.unwrap_or("sidelinja")
|
|
.to_string();
|
|
let db_name = payload["db_name"]
|
|
.as_str()
|
|
.unwrap_or("sidelinja")
|
|
.to_string();
|
|
let retain_days = payload["retain_days"].as_u64().unwrap_or(30) as u32;
|
|
(mode, backup_dir, pg_container, db_user, db_name, retain_days)
|
|
} else {
|
|
let mode = if cli.incremental {
|
|
Mode::Incremental
|
|
} else {
|
|
Mode::Full // default
|
|
};
|
|
(
|
|
mode,
|
|
cli.backup_dir,
|
|
cli.pg_container,
|
|
cli.db_user,
|
|
cli.db_name,
|
|
cli.retain_days,
|
|
)
|
|
};
|
|
|
|
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
|
let timestamp_iso = Utc::now().to_rfc3339();
|
|
|
|
// Opprett backup-kataloger
|
|
let pg_dir = PathBuf::from(&backup_dir).join("pg");
|
|
let manifest_dir = PathBuf::from(&backup_dir).join("manifests");
|
|
for dir in [&pg_dir, &manifest_dir] {
|
|
if let Err(e) = tokio::fs::create_dir_all(dir).await {
|
|
let result = BackupResult {
|
|
ok: false,
|
|
mode: mode_str(mode).to_string(),
|
|
timestamp: timestamp_iso.clone(),
|
|
backup_dir: backup_dir.clone(),
|
|
pg_dump: None,
|
|
cas_manifest: None,
|
|
metadata_file: None,
|
|
rotation: None,
|
|
error: Some(format!("Kunne ikke opprette katalog {}: {e}", dir.display())),
|
|
};
|
|
println!("{}", serde_json::to_string_pretty(&result).unwrap());
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
let mut all_ok = true;
|
|
let mut pg_dump_result = None;
|
|
let mut cas_manifest_result = None;
|
|
let mut rotation_result = None;
|
|
let mut error_msg = None;
|
|
|
|
// 1. PG-dump
|
|
tracing::info!(mode = mode_str(mode), "Starter backup");
|
|
match run_pg_dump(&pg_container, &db_user, &db_name, &pg_dir, ×tamp).await {
|
|
Ok(r) => {
|
|
tracing::info!(path = %r.path, size = r.size_human, "PG-dump ferdig");
|
|
pg_dump_result = Some(r);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(error = %e, "PG-dump feilet");
|
|
all_ok = false;
|
|
error_msg = Some(e);
|
|
}
|
|
}
|
|
|
|
// 2. CAS-filiste
|
|
let cas_root = synops_common::cas::root();
|
|
match build_cas_manifest(&cas_root, &manifest_dir, ×tamp, mode).await {
|
|
Ok(r) => {
|
|
tracing::info!(
|
|
files = r.file_count,
|
|
size = r.total_size_human,
|
|
"CAS-manifest ferdig"
|
|
);
|
|
cas_manifest_result = Some(r);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(error = %e, "CAS-manifest feilet");
|
|
all_ok = false;
|
|
if let Some(ref mut existing) = error_msg {
|
|
existing.push_str(&format!("; CAS: {e}"));
|
|
} else {
|
|
error_msg = Some(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Metadata-snapshot
|
|
let hostname = std::env::var("HOSTNAME")
|
|
.or_else(|_| {
|
|
std::process::Command::new("hostname")
|
|
.output()
|
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
})
|
|
.unwrap_or_else(|_| "unknown".to_string());
|
|
|
|
let metadata = MetadataSnapshot {
|
|
timestamp: timestamp_iso.clone(),
|
|
mode: mode_str(mode).to_string(),
|
|
pg_dump_size_bytes: pg_dump_result.as_ref().map(|r| r.size_bytes),
|
|
cas_file_count: cas_manifest_result.as_ref().map(|r| r.file_count),
|
|
cas_total_size_bytes: cas_manifest_result.as_ref().map(|r| r.total_size_bytes),
|
|
hostname,
|
|
db_name: db_name.clone(),
|
|
};
|
|
|
|
let metadata_path = manifest_dir.join(format!("backup_{timestamp}.json"));
|
|
let metadata_json = serde_json::to_string_pretty(&metadata).unwrap();
|
|
if let Err(e) = tokio::fs::write(&metadata_path, &metadata_json).await {
|
|
tracing::error!(error = %e, "Kunne ikke skrive metadata-snapshot");
|
|
all_ok = false;
|
|
}
|
|
|
|
// 4. Rotasjon (kun ved full backup)
|
|
if mode == Mode::Full && all_ok {
|
|
match rotate_old_backups(&pg_dir, retain_days).await {
|
|
Ok(r) => {
|
|
if r.deleted_count > 0 {
|
|
tracing::info!(
|
|
deleted = r.deleted_count,
|
|
retain_days = r.retain_days,
|
|
"Rotasjon ferdig"
|
|
);
|
|
}
|
|
rotation_result = Some(r);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "Rotasjon feilet (backup er OK)");
|
|
// Rotasjonsfeil er ikke kritisk — backupen er allerede tatt
|
|
}
|
|
}
|
|
}
|
|
|
|
// Output
|
|
let result = BackupResult {
|
|
ok: all_ok,
|
|
mode: mode_str(mode).to_string(),
|
|
timestamp: timestamp_iso,
|
|
backup_dir,
|
|
pg_dump: pg_dump_result,
|
|
cas_manifest: cas_manifest_result,
|
|
metadata_file: Some(metadata_path.to_string_lossy().to_string()),
|
|
rotation: rotation_result,
|
|
error: error_msg,
|
|
};
|
|
|
|
println!("{}", serde_json::to_string_pretty(&result).unwrap());
|
|
|
|
if !all_ok {
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn mode_str(mode: Mode) -> &'static str {
|
|
match mode {
|
|
Mode::Full => "full",
|
|
Mode::Incremental => "incremental",
|
|
}
|
|
}
|
|
|
|
/// Kjør pg_dump via docker exec. Returnerer dump-filsti og størrelse.
|
|
async fn run_pg_dump(
|
|
container: &str,
|
|
db_user: &str,
|
|
db_name: &str,
|
|
pg_dir: &Path,
|
|
timestamp: &str,
|
|
) -> Result<PgDumpResult, String> {
|
|
// Sjekk at containeren kjører
|
|
let inspect = tokio::process::Command::new("docker")
|
|
.args(["inspect", container, "--format", "{{.State.Running}}"])
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Kunne ikke kjøre docker inspect: {e}"))?;
|
|
|
|
let running = String::from_utf8_lossy(&inspect.stdout).trim().to_string();
|
|
if running != "true" {
|
|
return Err(format!("PostgreSQL-container {container} kjører ikke"));
|
|
}
|
|
|
|
let dump_file = pg_dir.join(format!("{db_name}_{timestamp}.dump"));
|
|
|
|
// pg_dump via docker exec, output til fil
|
|
let output = tokio::process::Command::new("docker")
|
|
.args([
|
|
"exec", container, "pg_dump", "-U", db_user, "-Fc", db_name,
|
|
])
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Kunne ikke kjøre pg_dump: {e}"))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("pg_dump feilet: {stderr}"));
|
|
}
|
|
|
|
// Skriv dump til fil
|
|
tokio::fs::write(&dump_file, &output.stdout)
|
|
.await
|
|
.map_err(|e| format!("Kunne ikke skrive dump-fil: {e}"))?;
|
|
|
|
let size_bytes = output.stdout.len() as u64;
|
|
if size_bytes < 100 {
|
|
// Slett tom/korrupt dump
|
|
let _ = tokio::fs::remove_file(&dump_file).await;
|
|
return Err(format!(
|
|
"Dump-filen er for liten ({size_bytes} bytes), noe gikk galt"
|
|
));
|
|
}
|
|
|
|
Ok(PgDumpResult {
|
|
path: dump_file.to_string_lossy().to_string(),
|
|
size_bytes,
|
|
size_human: human_size(size_bytes),
|
|
})
|
|
}
|
|
|
|
/// Bygg CAS-manifest: liste over alle filer med hash og størrelse.
|
|
///
|
|
/// Full modus: lister alle filer.
|
|
/// Inkrementell: lister kun filer nyere enn forrige manifest.
|
|
async fn build_cas_manifest(
|
|
cas_root: &str,
|
|
manifest_dir: &Path,
|
|
timestamp: &str,
|
|
mode: Mode,
|
|
) -> Result<CasManifestResult, String> {
|
|
let cas_path = Path::new(cas_root);
|
|
if !cas_path.exists() {
|
|
return Err(format!("CAS-katalog {cas_root} finnes ikke"));
|
|
}
|
|
|
|
// For inkrementell: finn tidsstempel for forrige manifest
|
|
let cutoff = if mode == Mode::Incremental {
|
|
find_last_manifest_time(manifest_dir).await
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Bruk find for å liste CAS-filer effektivt
|
|
let mut args = vec![cas_root.to_string(), "-type".to_string(), "f".to_string()];
|
|
if let Some(ref cutoff_time) = cutoff {
|
|
// Kun filer nyere enn cutoff
|
|
args.extend(["-newer".to_string(), cutoff_time.clone()]);
|
|
}
|
|
|
|
let output = tokio::process::Command::new("find")
|
|
.args(&args)
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Kunne ikke liste CAS-filer: {e}"))?;
|
|
|
|
let file_list = String::from_utf8_lossy(&output.stdout);
|
|
let mut file_count: u64 = 0;
|
|
let mut total_size: u64 = 0;
|
|
let mut manifest_lines = Vec::new();
|
|
|
|
for line in file_list.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
let path = Path::new(line);
|
|
let size = match tokio::fs::metadata(path).await {
|
|
Ok(m) => m.len(),
|
|
Err(_) => continue,
|
|
};
|
|
|
|
// Extraher hash fra filnavn (siste path-komponent)
|
|
let hash = path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
|
|
manifest_lines.push(format!("{hash}\t{size}"));
|
|
file_count += 1;
|
|
total_size += size;
|
|
}
|
|
|
|
let manifest_name = if mode == Mode::Incremental {
|
|
format!("cas_incremental_{timestamp}.tsv")
|
|
} else {
|
|
format!("cas_full_{timestamp}.tsv")
|
|
};
|
|
let manifest_path = manifest_dir.join(&manifest_name);
|
|
|
|
// Skriv manifest (TSV: hash\tsize)
|
|
let mode_label = mode_str(mode);
|
|
let header = format!("# CAS manifest — {mode_label} — {timestamp}\n# hash\tsize_bytes\n");
|
|
let content = format!("{}{}\n", header, manifest_lines.join("\n"));
|
|
tokio::fs::write(&manifest_path, &content)
|
|
.await
|
|
.map_err(|e| format!("Kunne ikke skrive CAS-manifest: {e}"))?;
|
|
|
|
Ok(CasManifestResult {
|
|
path: manifest_path.to_string_lossy().to_string(),
|
|
file_count,
|
|
total_size_bytes: total_size,
|
|
total_size_human: human_size(total_size),
|
|
})
|
|
}
|
|
|
|
/// Finn tidspunkt for forrige manifest (brukes som cutoff for inkrementell).
|
|
async fn find_last_manifest_time(manifest_dir: &Path) -> Option<String> {
|
|
let mut entries = match tokio::fs::read_dir(manifest_dir).await {
|
|
Ok(e) => e,
|
|
Err(_) => return None,
|
|
};
|
|
|
|
let mut latest_path: Option<PathBuf> = None;
|
|
let mut latest_modified = std::time::SystemTime::UNIX_EPOCH;
|
|
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
if name.starts_with("cas_") && name.ends_with(".tsv") {
|
|
if let Ok(meta) = entry.metadata().await {
|
|
if let Ok(modified) = meta.modified() {
|
|
if modified > latest_modified {
|
|
latest_modified = modified;
|
|
latest_path = Some(entry.path());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
latest_path.map(|p| p.to_string_lossy().to_string())
|
|
}
|
|
|
|
/// Slett PG-dumper eldre enn retain_days.
|
|
async fn rotate_old_backups(pg_dir: &Path, retain_days: u32) -> Result<RotationResult, String> {
|
|
let cutoff = std::time::SystemTime::now()
|
|
- std::time::Duration::from_secs(retain_days as u64 * 86400);
|
|
|
|
let mut entries = tokio::fs::read_dir(pg_dir)
|
|
.await
|
|
.map_err(|e| format!("Kunne ikke lese backup-katalog: {e}"))?;
|
|
|
|
let mut deleted_count = 0u32;
|
|
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
if !name.ends_with(".dump") {
|
|
continue;
|
|
}
|
|
if let Ok(meta) = entry.metadata().await {
|
|
if let Ok(modified) = meta.modified() {
|
|
if modified < cutoff {
|
|
if tokio::fs::remove_file(entry.path()).await.is_ok() {
|
|
tracing::info!(file = %name, "Slettet gammel dump");
|
|
deleted_count += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(RotationResult {
|
|
deleted_count,
|
|
retain_days,
|
|
})
|
|
}
|
|
|
|
/// Formater bytes til lesbar størrelse.
|
|
fn human_size(bytes: u64) -> String {
|
|
const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"];
|
|
let mut size = bytes as f64;
|
|
for unit in UNITS {
|
|
if size < 1024.0 {
|
|
return format!("{size:.1} {unit}");
|
|
}
|
|
size /= 1024.0;
|
|
}
|
|
format!("{size:.1} PiB")
|
|
}
|