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.
220 lines
7.4 KiB
Rust
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, ¤t) {
|
|
(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)
|
|
}
|