Files
agent-manager/src/commands/update_cmd.rs
T
bruno d9ffc34156 feat: phase 2 — audit config + update --rollback (closes #63 #64)
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.
2026-08-18 09:44:16 -04:00

220 lines
7.4 KiB
Rust

//! update: compare the installed version with the latest available one and
//! reinstall when an update exists.
use super::*;
use crate::backup;
use crate::events::{Event, EventKind};
use crate::installers;
use crate::runner::{Runner, SystemRunner};
use anyhow::{bail, Result};
use std::cmp::Ordering;
use std::path::PathBuf;
pub fn run(
app: &App,
agent_name: Option<&str>,
all: bool,
rollback: Option<&str>,
) -> Result<i32> {
// Issue #64: --rollback restores a pre-update backup instead of updating.
if rollback.is_some() {
return rollback_point(app, rollback);
}
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
let policy = app.config.settings.update_policy.unwrap_or_default();
if policy == UpdatePolicy::None && all {
app.log.warn("update policy is 'none'; 'am update --all' is a no-op");
return Ok(0);
}
if let Some(name) = agent_name {
if policy == UpdatePolicy::None && !app.yes() {
app.log.warn("update policy is 'none'; use --yes to force a single-agent update");
}
return update_one(app, &runner, name);
}
if all {
let entries = app.state.all()?;
let names: Vec<String> = entries.keys().cloned().collect();
let mut failed = 0;
// Issue #64: one snapshot covers the whole batch.
if !app.dry_run() && !names.is_empty() {
let info = backup::create_backup(app, "update:--all", &names)?;
app.log.verbose(&format!("pre-update backup: {}", info.id));
app.emit(
&Event::now(EventKind::Backup)
.with_args(vec![info.id.clone()])
.with_reason("update:--all"),
);
}
for name in names {
if let Err(e) = update_one(app, &runner, &name) {
failed += 1;
app.log.error(&format!("{name}: {e:#}"));
}
}
return Ok(if failed > 0 { 1 } else { 0 });
}
bail!("update requires an agent name, or --all");
}
fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
let agent = require_agent(app, name)?;
// Issue #65: honor per-agent pinned version.
if let Some(pin) = agent.pin_version.as_deref() {
let current = app
.state
.get(&agent.name)
.ok()
.flatten()
.and_then(|e| e.version.clone());
if current.as_deref() == Some(pin) {
app.log.success(&format!(
"{} is pinned to v{pin} and already at that version",
agent.title()
));
} else {
app.log.warn(&format!(
"{} is pinned to v{pin} but currently at {}; update skipped",
agent.title(),
current.as_deref().unwrap_or("unknown")
));
}
return Ok(0);
}
let Some(entry) = app.state.get(&agent.name).ok().flatten() else {
bail!(
"agent '{}' is not installed by agent-manager — use 'am install {}'",
agent.name,
agent.name
);
};
let spec = agent
.install
.as_ref()
.ok_or_else(|| anyhow::anyhow!("agent '{}' has no install definition", agent.name))?;
let methods = spec.all_methods();
// Prefer the method that was originally used.
let method = methods
.iter()
.find(|m| m.kind.as_str() == entry.method)
.copied()
.unwrap_or(methods[0]);
let latest = if app.dry_run() {
None
} else {
installers::latest_version(app, agent, method, runner)?
};
let current = entry.version.clone().or_else(|| {
entry
.bins
.first()
.and_then(|b| installers::detect_version(runner, &PathBuf::from(b)))
});
match (&latest, &current) {
(Some(l), Some(c)) => match crate::version::compare(l, c) {
Some(Ordering::Greater) => {
app.log.info(&format!("update available: {c} -> {l}"));
}
Some(Ordering::Equal) | Some(Ordering::Less) => {
app.log
.success(&format!("{} is up to date (v{c})", agent.title()));
return Ok(0);
}
None => {
app.log.warn(&format!("cannot compare versions ({c} vs {l}); reinstalling"));
}
},
(Some(l), None) => app.log.info(&format!(
"latest available: {l} (installed version unknown); reinstalling"
)),
(None, Some(c)) => app.log.warn(&format!(
"cannot determine the latest version (installed: {c}); reinstalling"
)),
(None, None) => app.log.warn("cannot determine versions; reinstalling"),
}
if app.dry_run() {
app.log
.dry(format!("would reinstall {} via {}", agent.name, method.kind));
return Ok(0);
}
// Issue #64: snapshot before the real update so a broken upgrade can be
// undone with 'am update --rollback'.
let info = backup::create_backup(app, &format!("update:{}", agent.name), &[agent.name.clone()])?;
app.log.verbose(&format!("pre-update backup: {}", info.id));
app.emit(
&Event::now(EventKind::Backup)
.with_args(vec![info.id.clone()])
.with_agent(agent.name.clone())
.with_reason("pre-update"),
);
crate::deps::ensure_deps(app, agent, runner)?;
let outcome = installers::run_install(app, agent, method, runner)?;
let mut new_entry = installers::make_entry(agent, method, &outcome, app);
new_entry.installed_at = entry.installed_at.clone();
new_entry.updated_at = Some(installers::now_rfc3339());
let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string());
app.state.set(&new_entry)?;
app.emit(
&Event::now(EventKind::Update)
.with_agent(agent.name.clone())
.with_args(vec![format!("method={}", method.kind)]),
);
crate::hooks::run_hooks(
app,
"on_update",
&std::env::current_dir().unwrap_or_default(),
);
app.log.success(&format!(
"updated {} to v{version}",
agent.title()
));
Ok(0)
}
/// Issue #64: 'am update --rollback [point]' — restore a pre-update backup.
/// '--rollback list' prints the available backup points.
fn rollback_point(app: &App, point: Option<&str>) -> Result<i32> {
if point == Some("list") {
let all = backup::list_backups(app);
if all.is_empty() {
app.log.info("no backup found — run an update first to create one");
return Ok(0);
}
println!("pre-update backups ({}):", all.len());
for b in &all {
println!(
" {} {} agents={}{}",
b.id,
b.reason,
if b.agents.is_empty() {
"all".to_string()
} else {
b.agents.join(",")
},
if b.state && b.config { " [state+config]" } else { "" }
);
}
println!("\nuse 'am update --rollback <id>' to restore one");
return Ok(0);
}
let info = backup::restore_backup(app, point)?;
app.emit(
&Event::now(EventKind::Rollback)
.with_args(vec![info.id.clone()])
.with_reason(&info.reason),
);
app.log.success(&format!(
"rolled back to backup {} ({})",
info.id, info.reason
));
Ok(0)
}