feat(cli): add update command with 6-phase pipeline and rollback
Add `zeroclaw update` command with a 6-phase self-update pipeline: 1. Preflight — check GitHub releases API for newer version 2. Download — fetch platform-specific binary to temp dir 3. Backup — copy current binary to .bak for rollback 4. Validate — size check + --version smoke test on download 5. Swap — overwrite current binary with new version 6. Smoke test — verify updated binary runs, rollback on failure Supports --check flag for update-check-only mode without installing. Includes version comparison logic with unit tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
72b7e1e647
commit
5bfa5f18e1
@ -1,3 +1,258 @@
|
||||
//! `zeroclaw update` — self-update pipeline with rollback.
|
||||
//!
|
||||
//! Placeholder module for the update command (PR-06).
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::Path;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const GITHUB_RELEASES_URL: &str =
|
||||
"https://api.github.com/repos/zeroclaw-labs/zeroclaw/releases/latest";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateInfo {
|
||||
pub current_version: String,
|
||||
pub latest_version: String,
|
||||
pub download_url: Option<String>,
|
||||
pub is_newer: bool,
|
||||
}
|
||||
|
||||
/// Check for available updates without downloading.
|
||||
pub async fn check() -> Result<UpdateInfo> {
|
||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(format!("zeroclaw/{current}"))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?;
|
||||
|
||||
let resp = client
|
||||
.get(GITHUB_RELEASES_URL)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to reach GitHub releases API")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
bail!("GitHub API returned {}", resp.status());
|
||||
}
|
||||
|
||||
let release: serde_json::Value = resp.json().await?;
|
||||
let tag = release["tag_name"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown")
|
||||
.trim_start_matches('v')
|
||||
.to_string();
|
||||
|
||||
let download_url = find_asset_url(&release);
|
||||
let is_newer = version_is_newer(¤t, &tag);
|
||||
|
||||
Ok(UpdateInfo {
|
||||
current_version: current,
|
||||
latest_version: tag,
|
||||
download_url,
|
||||
is_newer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the full 6-phase update pipeline.
|
||||
pub async fn run() -> Result<()> {
|
||||
// Phase 1: Preflight
|
||||
info!("Phase 1/6: Preflight checks...");
|
||||
let update_info = check().await?;
|
||||
|
||||
if !update_info.is_newer {
|
||||
println!("Already up to date (v{}).", update_info.current_version);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"Update available: v{} -> v{}",
|
||||
update_info.current_version, update_info.latest_version
|
||||
);
|
||||
|
||||
let download_url = update_info
|
||||
.download_url
|
||||
.context("no suitable binary found for this platform")?;
|
||||
|
||||
let current_exe =
|
||||
std::env::current_exe().context("cannot determine current executable path")?;
|
||||
|
||||
// Phase 2: Download
|
||||
info!("Phase 2/6: Downloading...");
|
||||
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
|
||||
let download_path = temp_dir.path().join("zeroclaw_new");
|
||||
download_binary(&download_url, &download_path).await?;
|
||||
|
||||
// Phase 3: Backup
|
||||
info!("Phase 3/6: Creating backup...");
|
||||
let backup_path = current_exe.with_extension("bak");
|
||||
tokio::fs::copy(¤t_exe, &backup_path)
|
||||
.await
|
||||
.context("failed to backup current binary")?;
|
||||
|
||||
// Phase 4: Validate
|
||||
info!("Phase 4/6: Validating download...");
|
||||
validate_binary(&download_path).await?;
|
||||
|
||||
// Phase 5: Swap
|
||||
info!("Phase 5/6: Swapping binary...");
|
||||
if let Err(e) = swap_binary(&download_path, ¤t_exe).await {
|
||||
// Rollback
|
||||
warn!("Swap failed, rolling back: {e}");
|
||||
if let Err(rollback_err) = tokio::fs::copy(&backup_path, ¤t_exe).await {
|
||||
eprintln!("CRITICAL: Rollback also failed: {rollback_err}");
|
||||
eprintln!(
|
||||
"Manual recovery: cp {} {}",
|
||||
backup_path.display(),
|
||||
current_exe.display()
|
||||
);
|
||||
}
|
||||
bail!("Update failed during swap: {e}");
|
||||
}
|
||||
|
||||
// Phase 6: Smoke test
|
||||
info!("Phase 6/6: Smoke test...");
|
||||
match smoke_test(¤t_exe).await {
|
||||
Ok(()) => {
|
||||
// Cleanup backup on success
|
||||
let _ = tokio::fs::remove_file(&backup_path).await;
|
||||
println!("Successfully updated to v{}!", update_info.latest_version);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Smoke test failed, rolling back: {e}");
|
||||
tokio::fs::copy(&backup_path, ¤t_exe)
|
||||
.await
|
||||
.context("rollback after smoke test failure")?;
|
||||
bail!("Update rolled back — smoke test failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_asset_url(release: &serde_json::Value) -> Option<String> {
|
||||
let target = if cfg!(target_os = "macos") {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
"aarch64-apple-darwin"
|
||||
} else {
|
||||
"x86_64-apple-darwin"
|
||||
}
|
||||
} else if cfg!(target_os = "linux") {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
"aarch64-unknown-linux"
|
||||
} else {
|
||||
"x86_64-unknown-linux"
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
release["assets"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|asset| {
|
||||
asset["name"]
|
||||
.as_str()
|
||||
.map(|name| name.contains(target))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.and_then(|asset| asset["browser_download_url"].as_str().map(String::from))
|
||||
}
|
||||
|
||||
fn version_is_newer(current: &str, candidate: &str) -> bool {
|
||||
let parse = |v: &str| -> Vec<u32> { v.split('.').filter_map(|p| p.parse().ok()).collect() };
|
||||
let cur = parse(current);
|
||||
let cand = parse(candidate);
|
||||
cand > cur
|
||||
}
|
||||
|
||||
async fn download_binary(url: &str, dest: &Path) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(format!("zeroclaw/{}", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()?;
|
||||
|
||||
let resp = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.context("download request failed")?;
|
||||
if !resp.status().is_success() {
|
||||
bail!("download returned {}", resp.status());
|
||||
}
|
||||
|
||||
let bytes = resp.bytes().await.context("failed to read download body")?;
|
||||
tokio::fs::write(dest, &bytes)
|
||||
.await
|
||||
.context("failed to write downloaded binary")?;
|
||||
|
||||
// Make executable on Unix
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o755);
|
||||
tokio::fs::set_permissions(dest, perms).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_binary(path: &Path) -> Result<()> {
|
||||
let meta = tokio::fs::metadata(path).await?;
|
||||
if meta.len() < 1_000_000 {
|
||||
bail!(
|
||||
"downloaded binary too small ({} bytes), likely corrupt",
|
||||
meta.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Quick check: try running --version
|
||||
let output = tokio::process::Command::new(path)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.context("cannot execute downloaded binary")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("downloaded binary --version check failed");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if !stdout.contains("zeroclaw") {
|
||||
bail!("downloaded binary does not appear to be zeroclaw");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn swap_binary(new: &Path, target: &Path) -> Result<()> {
|
||||
tokio::fs::copy(new, target)
|
||||
.await
|
||||
.context("failed to overwrite binary")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn smoke_test(binary: &Path) -> Result<()> {
|
||||
let output = tokio::process::Command::new(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.context("smoke test: cannot execute updated binary")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("smoke test: updated binary returned non-zero exit code");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version_comparison() {
|
||||
assert!(version_is_newer("0.4.3", "0.5.0"));
|
||||
assert!(version_is_newer("0.4.3", "0.4.4"));
|
||||
assert!(!version_is_newer("0.5.0", "0.4.3"));
|
||||
assert!(!version_is_newer("0.4.3", "0.4.3"));
|
||||
assert!(version_is_newer("1.0.0", "2.0.0"));
|
||||
}
|
||||
}
|
||||
|
||||
36
src/main.rs
36
src/main.rs
@ -463,6 +463,25 @@ Examples:
|
||||
config_command: ConfigCommands,
|
||||
},
|
||||
|
||||
/// Check for and apply updates
|
||||
#[command(long_about = "\
|
||||
Check for and apply ZeroClaw updates.
|
||||
|
||||
By default, downloads and installs the latest release with a \
|
||||
6-phase pipeline: preflight, download, backup, validate, swap, \
|
||||
and smoke test. Automatic rollback on failure.
|
||||
|
||||
Use --check to only check for updates without installing.
|
||||
|
||||
Examples:
|
||||
zeroclaw update # download and install latest
|
||||
zeroclaw update --check # check only, don't install")]
|
||||
Update {
|
||||
/// Only check for updates, don't install
|
||||
#[arg(long)]
|
||||
check: bool,
|
||||
},
|
||||
|
||||
/// Run diagnostic self-tests
|
||||
#[command(long_about = "\
|
||||
Run diagnostic self-tests to verify the ZeroClaw installation.
|
||||
@ -1224,6 +1243,23 @@ async fn main() -> Result<()> {
|
||||
.await
|
||||
}
|
||||
|
||||
Commands::Update { check } => {
|
||||
if check {
|
||||
let info = commands::update::check().await?;
|
||||
if info.is_newer {
|
||||
println!(
|
||||
"Update available: v{} -> v{}",
|
||||
info.current_version, info.latest_version
|
||||
);
|
||||
} else {
|
||||
println!("Already up to date (v{}).", info.current_version);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
commands::update::run().await
|
||||
}
|
||||
}
|
||||
|
||||
Commands::SelfTest { quick } => {
|
||||
let results = if quick {
|
||||
commands::self_test::run_quick(&config).await?
|
||||
|
||||
Loading…
Reference in New Issue
Block a user