Axe 8 (sécurité) : - am audit : checksums SHA-256 des configs (embarquée, utilisateur, locale) stockés dans state.json, croisement avec events.jsonl pour dater les changements, détection des modifications manuelles hors de am (statut MANUAL), rapport lisible + --json stable (contrat) - backup automatique avant chaque am update (agent ou --all) : état + config utilisateur + répertoires d'installation + shims dans <state>/backups/<ts>/ avec manifest.json - am update --rollback [point] : restaure le dernier backup (ou un id précis, préfixe accepté), arrête les agents concernés d'abord, événement EventKind::Rollback journalisé - am update --rollback list : liste les points de restauration - rétention settings.backups_keep (défaut 5) appliquée après chaque backup - événements EventKind::Backup / EventKind::Rollback pour l'audit REPL : commande audit + update --rollback (parse, complétion), help specs, tip cheat sheet, man pages régénérées (50 pages). Version 0.4.6, ROADMAP cases cochées, 272 tests verts.
427 lines
15 KiB
Rust
427 lines
15 KiB
Rust
//! Pre-update backups (issue #64, axe 8): a snapshot of the install
|
|
//! directory, the state database and the user config is taken before every
|
|
//! real update, kept under <state>/backups/<timestamp>/, and restored by
|
|
//! 'am update --rollback <point>'. Retention is bounded by
|
|
//! settings.backups_keep (default 5).
|
|
|
|
use crate::app::App;
|
|
use anyhow::{anyhow, bail, Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Default number of backups to keep (settings.backups_keep default).
|
|
pub const DEFAULT_KEEP: u32 = 5;
|
|
|
|
/// Manifest of one backup point (JSON file inside the backup directory).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BackupInfo {
|
|
/// Backup id = the directory name (RFC 3339 timestamp).
|
|
pub id: String,
|
|
/// RFC 3339 creation timestamp.
|
|
pub created_at: String,
|
|
/// Reason: "update:<agent>" or "update:--all".
|
|
pub reason: String,
|
|
/// Agents whose install directories were snapshotted (empty = all).
|
|
pub agents: Vec<String>,
|
|
/// True when the state database was snapshotted.
|
|
pub state: bool,
|
|
/// True when the user config file was snapshotted.
|
|
pub config: bool,
|
|
}
|
|
|
|
impl BackupInfo {
|
|
/// Load the manifest of one backup directory.
|
|
fn load(dir: &Path) -> Option<BackupInfo> {
|
|
let text = std::fs::read_to_string(dir.join("manifest.json")).ok()?;
|
|
serde_json::from_str(&text).ok()
|
|
}
|
|
}
|
|
|
|
/// Directory holding every backup point. Lives next to the state database
|
|
/// so tests and --config overrides stay fully sandboxed.
|
|
pub fn backups_dir(app: &App) -> PathBuf {
|
|
app.paths
|
|
.state_file
|
|
.parent()
|
|
.unwrap_or_else(|| std::path::Path::new("."))
|
|
.join("backups")
|
|
}
|
|
|
|
/// Take a snapshot before an update. `agents` lists the agents being
|
|
/// updated; an empty list means "the whole install directory".
|
|
pub fn create_backup(app: &App, reason: &str, agents: &[String]) -> Result<BackupInfo> {
|
|
let now = crate::installers::now_rfc3339();
|
|
// Directory names must be sortable and filesystem-safe: the RFC 3339
|
|
// timestamp with colons replaced by dashes.
|
|
let id = now.replace(':', "-").replace('Z', "").replace('+', "_");
|
|
let root = backups_dir(app).join(&id);
|
|
std::fs::create_dir_all(&root)
|
|
.with_context(|| format!("cannot create backup directory {}", root.display()))?;
|
|
|
|
let mut info = BackupInfo {
|
|
id: id.clone(),
|
|
created_at: now,
|
|
reason: reason.to_string(),
|
|
agents: agents.to_vec(),
|
|
state: false,
|
|
config: false,
|
|
};
|
|
|
|
// 1. State database.
|
|
if let Ok(sf) = app.state.load() {
|
|
let text = serde_json::to_string_pretty(&sf)?;
|
|
std::fs::write(root.join("state.json"), text)
|
|
.with_context(|| "cannot snapshot state.json".to_string())?;
|
|
info.state = true;
|
|
}
|
|
|
|
// 2. User config file (the active origin when it is a user file).
|
|
if let Some(origin) = &app.config_origin {
|
|
if origin.is_file() {
|
|
std::fs::copy(origin, root.join("config.yaml")).with_context(|| {
|
|
format!("cannot snapshot user config {}", origin.display())
|
|
})?;
|
|
info.config = true;
|
|
}
|
|
}
|
|
|
|
// 3. Install directories of the updated agents (or everything).
|
|
let install_root = &app.paths.install_dir;
|
|
let dest_root = root.join("install");
|
|
std::fs::create_dir_all(&dest_root)?;
|
|
if agents.is_empty() {
|
|
copy_tree(install_root, &dest_root)
|
|
.with_context(|| format!("cannot snapshot {}", install_root.display()))?;
|
|
} else {
|
|
for name in agents {
|
|
let entry = app.state.get(name).ok().flatten();
|
|
if let Some(e) = entry {
|
|
let src = PathBuf::from(&e.install_dir);
|
|
if src.is_dir() {
|
|
let dest = dest_root.join(&e.name);
|
|
copy_tree(&src, &dest).with_context(|| {
|
|
format!("cannot snapshot {} for {name}", src.display())
|
|
})?;
|
|
}
|
|
}
|
|
// Also snapshot the shim binaries (bin/<name>).
|
|
let shim = app.paths.bin_dir.join(name);
|
|
if shim.exists() {
|
|
copy_tree(&shim, &dest_root.join("bin").join(name))?;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write the manifest last: a backup without a manifest is invalid.
|
|
let manifest = serde_json::to_string_pretty(&info)?;
|
|
std::fs::write(root.join("manifest.json"), manifest)
|
|
.with_context(|| "cannot write backup manifest".to_string())?;
|
|
|
|
prune(app)?;
|
|
Ok(info)
|
|
}
|
|
|
|
/// List every backup point, oldest first.
|
|
pub fn list_backups(app: &App) -> Vec<BackupInfo> {
|
|
let mut out: Vec<BackupInfo> = Vec::new();
|
|
if let Ok(entries) = std::fs::read_dir(backups_dir(app)) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
if let Some(info) = BackupInfo::load(&path) {
|
|
out.push(info);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out.sort_by(|a, b| a.id.cmp(&b.id));
|
|
out
|
|
}
|
|
|
|
/// Resolve a rollback point: "latest"/empty = most recent backup,
|
|
/// otherwise the backup id (prefix matching allowed).
|
|
pub fn resolve_point(app: &App, point: Option<&str>) -> Result<BackupInfo> {
|
|
let all = list_backups(app);
|
|
if all.is_empty() {
|
|
bail!("no backup found — nothing to roll back to");
|
|
}
|
|
match point {
|
|
None | Some("latest") => all
|
|
.last()
|
|
.cloned()
|
|
.ok_or_else(|| anyhow!("no backup found")),
|
|
Some(p) => {
|
|
let exact = all.iter().find(|b| b.id == p);
|
|
if let Some(b) = exact {
|
|
return Ok(b.clone());
|
|
}
|
|
let prefix: Vec<BackupInfo> = all
|
|
.iter()
|
|
.filter(|b| b.id.starts_with(p))
|
|
.cloned()
|
|
.collect();
|
|
match prefix.len() {
|
|
0 => bail!("backup '{p}' not found — run 'am update --rollback --list'"),
|
|
1 => Ok(prefix[0].clone()),
|
|
_ => bail!(
|
|
"backup prefix '{p}' is ambiguous ({} matches: {})",
|
|
prefix.len(),
|
|
prefix
|
|
.iter()
|
|
.map(|b| b.id.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Restore a backup point over the live install: state, user config and
|
|
/// install directories. The agent processes of the restored agents are
|
|
/// stopped first (they would hold the old binaries open).
|
|
pub fn restore_backup(app: &App, point: Option<&str>) -> Result<BackupInfo> {
|
|
let info = resolve_point(app, point)?;
|
|
let root = backups_dir(app).join(&info.id);
|
|
if !root.join("manifest.json").is_file() {
|
|
bail!("backup {} is corrupted (no manifest)", info.id);
|
|
}
|
|
|
|
// Stop the agents being restored (only managed ones we know).
|
|
let names: Vec<String> = if info.agents.is_empty() {
|
|
app.state
|
|
.all()?
|
|
.keys()
|
|
.cloned()
|
|
.collect()
|
|
} else {
|
|
info.agents.clone()
|
|
};
|
|
for name in &names {
|
|
if let Ok(Some(entry)) = app.state.get(name) {
|
|
if let Some(pid) = entry.pid {
|
|
if crate::process::is_running(pid) {
|
|
app.log
|
|
.info(&format!("stopping {name} (pid {pid}) before rollback"));
|
|
let _ = crate::commands::run_cmd::stop(app, name, false, None);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1. Restore the state database.
|
|
if info.state {
|
|
let src = root.join("state.json");
|
|
if src.is_file() {
|
|
std::fs::copy(&src, app.state.path())
|
|
.with_context(|| "cannot restore state.json".to_string())?;
|
|
}
|
|
}
|
|
|
|
// 2. Restore the user config.
|
|
if info.config {
|
|
if let Some(origin) = &app.config_origin {
|
|
if let Some(parent) = origin.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
std::fs::copy(root.join("config.yaml"), origin)
|
|
.with_context(|| format!("cannot restore {}", origin.display()))?;
|
|
}
|
|
}
|
|
|
|
// 3. Restore the install directories.
|
|
let src_root = root.join("install");
|
|
if src_root.is_dir() {
|
|
if info.agents.is_empty() {
|
|
if app.paths.install_dir.exists() {
|
|
std::fs::remove_dir_all(&app.paths.install_dir)
|
|
.with_context(|| "cannot clear the install directory".to_string())?;
|
|
}
|
|
copy_tree(&src_root, &app.paths.install_dir)?;
|
|
} else {
|
|
for name in &info.agents {
|
|
let src = src_root.join(name);
|
|
if !src.exists() {
|
|
continue;
|
|
}
|
|
// Remove the agent's current install dir (known from the
|
|
// restored state) and copy the snapshot back.
|
|
if let Ok(Some(entry)) = app.state.get(name) {
|
|
let cur = PathBuf::from(&entry.install_dir);
|
|
if cur.is_dir() {
|
|
let _ = std::fs::remove_dir_all(&cur);
|
|
}
|
|
}
|
|
let dest = app.paths.install_dir.join(name);
|
|
copy_tree(&src, &dest)?;
|
|
}
|
|
// Restore the shims.
|
|
let bin_src = src_root.join("bin");
|
|
if bin_src.is_dir() {
|
|
copy_tree(&bin_src, &app.paths.bin_dir)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(info)
|
|
}
|
|
|
|
/// Enforce settings.backups_keep: delete the oldest backups beyond the cap.
|
|
pub fn prune(app: &App) -> Result<()> {
|
|
let keep = app.config.settings.backups_keep.unwrap_or(DEFAULT_KEEP) as usize;
|
|
let all = list_backups(app);
|
|
if all.len() <= keep {
|
|
return Ok(());
|
|
}
|
|
for old in all.iter().take(all.len() - keep) {
|
|
let dir = backups_dir(app).join(&old.id);
|
|
if dir.is_dir() {
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Recursively copy a directory tree (files and directories only; symlinks
|
|
/// are copied as-is when possible).
|
|
fn copy_tree(src: &Path, dest: &Path) -> Result<()> {
|
|
if !src.exists() {
|
|
return Ok(());
|
|
}
|
|
let meta = std::fs::metadata(src)?;
|
|
if meta.is_file() {
|
|
if let Some(parent) = dest.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
std::fs::copy(src, dest)?;
|
|
return Ok(());
|
|
}
|
|
std::fs::create_dir_all(dest)?;
|
|
let mut entries: Vec<_> = std::fs::read_dir(src)?
|
|
.flatten()
|
|
.map(|e| e.path())
|
|
.collect();
|
|
entries.sort();
|
|
for child in entries {
|
|
let name = child
|
|
.file_name()
|
|
.ok_or_else(|| anyhow!("invalid path {}", child.display()))?;
|
|
copy_tree(&child, &dest.join(name))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::cli::Cli;
|
|
use crate::state::StateFile;
|
|
use clap::Parser;
|
|
use std::io::Write;
|
|
|
|
fn test_app(tag: &str) -> App {
|
|
let dir = std::env::temp_dir().join(format!("am-backup-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let cfg = dir.join("config.yaml");
|
|
std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap();
|
|
let cli = Cli::parse_from([
|
|
"am",
|
|
"--config",
|
|
cfg.to_str().unwrap(),
|
|
"--yes",
|
|
"--no-color",
|
|
]);
|
|
let mut app = App::from_cli(cli).expect("app builds");
|
|
// Point the state/install dirs into the temp dir so nothing touches
|
|
// the real user data.
|
|
let state_file = dir.join("state.json");
|
|
let install = dir.join("agents");
|
|
std::fs::create_dir_all(&install).unwrap();
|
|
app.paths.state_file = state_file.clone();
|
|
app.paths.install_dir = install.clone();
|
|
app.paths.bin_dir = install.join("bin");
|
|
app.state = crate::state::StateStore::new(state_file);
|
|
app.config_origin = Some(cfg);
|
|
// Fresh backups dir for this test run.
|
|
let _ = std::fs::remove_dir_all(backups_dir(&app));
|
|
app
|
|
}
|
|
|
|
fn write_agent_file(app: &App, name: &str, content: &str) -> PathBuf {
|
|
let dir = app.paths.install_dir.join(name);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let f = dir.join("agent.txt");
|
|
let mut h = std::fs::File::create(&f).unwrap();
|
|
h.write_all(content.as_bytes()).unwrap();
|
|
f
|
|
}
|
|
|
|
#[test]
|
|
fn backup_create_list_restore_roundtrip() {
|
|
let app = test_app("roundtrip");
|
|
write_agent_file(&app, "alpha", "v1");
|
|
write_agent_file(&app, "beta", "b1");
|
|
// Simulate a managed entry for alpha.
|
|
let mut sf = StateFile::default();
|
|
sf.installed.insert(
|
|
"alpha".to_string(),
|
|
crate::state::InstalledEntry {
|
|
name: "alpha".to_string(),
|
|
version: Some("1.0.0".to_string()),
|
|
method: "binary".to_string(),
|
|
install_dir: app.paths.install_dir.join("alpha").display().to_string(),
|
|
bins: vec![],
|
|
run: "alpha".to_string(),
|
|
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
|
updated_at: None,
|
|
pid: None,
|
|
started_at: None,
|
|
},
|
|
);
|
|
app.state.save(&sf).unwrap();
|
|
|
|
let info = create_backup(&app, "update:alpha", &["alpha".to_string()]).unwrap();
|
|
assert!(info.state && info.config);
|
|
|
|
// Corrupt the live install + state.
|
|
write_agent_file(&app, "alpha", "BROKEN");
|
|
let mut bad = StateFile::default();
|
|
bad.sessions_count = 999;
|
|
app.state.save(&bad).unwrap();
|
|
|
|
restore_backup(&app, Some(&info.id)).unwrap();
|
|
let text = std::fs::read_to_string(app.paths.install_dir.join("alpha/agent.txt")).unwrap();
|
|
assert_eq!(text, "v1", "install dir restored");
|
|
let sf = app.state.load().unwrap();
|
|
assert_eq!(sf.installed.get("alpha").unwrap().version.as_deref(), Some("1.0.0"));
|
|
assert_eq!(sf.sessions_count, 0, "state restored");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_point_prefix_and_latest() {
|
|
let app = test_app("resolve");
|
|
create_backup(&app, "update:a", &[]).unwrap();
|
|
let info = resolve_point(&app, None).unwrap();
|
|
assert_eq!(info.agents, Vec::<String>::new());
|
|
let prefix = &info.id[..info.id.len().min(10)];
|
|
let by_prefix = resolve_point(&app, Some(prefix)).unwrap();
|
|
assert_eq!(by_prefix.id, info.id);
|
|
}
|
|
|
|
#[test]
|
|
fn prune_keeps_only_backups_keep() {
|
|
let mut app = test_app("prune");
|
|
app.config.settings.backups_keep = Some(1);
|
|
create_backup(&app, "update:one", &[]).unwrap();
|
|
create_backup(&app, "update:two", &[]).unwrap();
|
|
let all = list_backups(&app);
|
|
assert_eq!(all.len(), 1, "only the newest backup survives");
|
|
}
|
|
}
|