From 3fab6373532193173180bc4377b7f72e86e0259a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 4 Jun 2026 13:52:23 -0700 Subject: [PATCH] C2(hephd-self-update): impl cargo-install-from-tag (injectable Installer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an Installer trait + CargoInstaller (runs cargo install --locked --git --tag for heph/hephd/heph-tui/heph-quickadd — the documented install command, via the SSH host that sidesteps the cargo/forge canonical-name mismatch), and apply_update() which runs the blocking install on the blocking pool. The poll loop now applies on a detected update. Apply path is unit-tested with a fake installer (call + failure paths); the real cargo subprocess is never run in tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hephd/src/selfupdate.rs | 112 ++++++++++++++++++++++++++++++--- crates/hephd/src/server.rs | 4 +- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/crates/hephd/src/selfupdate.rs b/crates/hephd/src/selfupdate.rs index a1354ac..fcaa181 100644 --- a/crates/hephd/src/selfupdate.rs +++ b/crates/hephd/src/selfupdate.rs @@ -1,11 +1,13 @@ //! Opt-in self-update (cards: `docs/how-to/self-update/`). When enabled, hephd //! polls the forge for a newer tagged release and rebuilds + restarts onto it. //! -//! This first slice is the **version check**: parse the running version, fetch -//! the latest release tag from the forge, and decide whether an update exists. -//! The decision logic is pure and unit-tested; the network fetch is isolated so -//! its JSON parsing can be tested against a sample body without a live forge. +//! The moving parts are dependency-injected behind traits — [`ReleaseSource`] +//! (where the latest tag comes from) and [`Installer`] (how the upgrade is +//! applied) — so the poll/apply logic is unit-tested without a live forge or a +//! real `cargo install`. The production wiring (`ForgeReleaseSource`, +//! `CargoInstaller`) is exercised only at runtime. +use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; @@ -139,15 +141,75 @@ pub async fn check_release(source: &S, current: &str) -> Check } } -/// The background poll loop (notify-only for now): tick on `interval`, check for -/// a newer release, and log the outcome. Runs forever; spawned as a task. -pub async fn run_poll_loop(source: S, interval: Duration, current: &'static str) { +/// The git SSH URL self-update installs from. The SSH host on port 2222 is the +/// proven path: cargo rejects the HTTPS host over its canonical-name mismatch +/// (`forge.ops.eblu.me` vs the advertised `forge.eblu.me`). See the +/// `service-env-forge-access` card. +pub const INSTALL_GIT_URL: &str = "ssh://forgejo@forge.ops.eblu.me:2222/eblume/hephaestus.git"; + +/// All workspace binaries, installed in lockstep so `heph`/`hephd`/`heph-tui` +/// never skew after an update. +pub const INSTALL_BINS: &[&str] = &["heph", "hephd", "heph-tui", "heph-quickadd"]; + +/// Applies a detected upgrade. Injectable so the apply path is testable without +/// spawning a real (minutes-long) `cargo install` (real impl: [`CargoInstaller`]). +pub trait Installer: Send + Sync + 'static { + /// Install the binaries for release `tag` (e.g. `v1.0.4`). Blocking. + fn install(&self, tag: &str) -> Result<()>; +} + +/// The production installer: `cargo install --locked --git --tag ` +/// for every workspace binary — the exact command the install how-to documents. +pub struct CargoInstaller; + +impl Installer for CargoInstaller { + fn install(&self, tag: &str) -> Result<()> { + let mut cmd = std::process::Command::new("cargo"); + cmd.args([ + "install", + "--locked", + "--git", + INSTALL_GIT_URL, + "--tag", + tag, + ]); + cmd.args(INSTALL_BINS); + let status = cmd.status().context("spawning cargo install")?; + if !status.success() { + anyhow::bail!("cargo install for {tag} exited with {status}"); + } + Ok(()) + } +} + +/// Apply a detected update: install the binaries for `tag`. The blocking install +/// runs on the blocking pool so it never stalls the async runtime. (Restarting +/// onto the new binary is layered on by the self-restart step.) +pub async fn apply_update(installer: Arc, tag: &str) -> Result<()> { + let tag = tag.to_string(); + tokio::task::spawn_blocking(move || installer.install(&tag)) + .await + .context("self-update install task panicked")? +} + +/// The background poll loop: tick on `interval`, check for a newer release, and +/// when one is available, apply it. Runs forever; spawned as a task. +pub async fn run_poll_loop( + source: S, + installer: Arc, + interval: Duration, + current: &'static str, +) { let mut tick = tokio::time::interval(interval); loop { tick.tick().await; match check_release(&source, current).await { CheckOutcome::UpdateAvailable(tag) => { - tracing::info!(%tag, current, "self-update: newer release available") + tracing::info!(%tag, current, "self-update: newer release available, applying"); + match apply_update(installer.clone(), &tag).await { + Ok(()) => tracing::info!(%tag, "self-update: installed new binaries"), + Err(e) => tracing::error!("self-update: install failed for {tag}: {e}"), + } } CheckOutcome::UpToDate => tracing::debug!(current, "self-update: up to date"), CheckOutcome::Failed(e) => tracing::warn!("self-update: release check failed: {e}"), @@ -167,6 +229,40 @@ mod tests { } } + /// Records install calls; optionally fails, to drive the apply path. + #[derive(Default)] + struct FakeInstaller { + installed: std::sync::Mutex>, + fail: bool, + } + impl Installer for FakeInstaller { + fn install(&self, tag: &str) -> Result<()> { + self.installed.lock().unwrap().push(tag.to_string()); + if self.fail { + anyhow::bail!("simulated install failure"); + } + Ok(()) + } + } + + #[tokio::test] + async fn apply_update_invokes_the_installer_with_the_tag() { + let inst = Arc::new(FakeInstaller::default()); + apply_update(inst.clone(), "v1.0.4").await.unwrap(); + assert_eq!(*inst.installed.lock().unwrap(), vec!["v1.0.4".to_string()]); + } + + #[tokio::test] + async fn apply_update_propagates_install_failure() { + let inst = Arc::new(FakeInstaller { + fail: true, + ..Default::default() + }); + assert!(apply_update(inst.clone(), "v1.0.4").await.is_err()); + // It still attempted the install for the right tag. + assert_eq!(*inst.installed.lock().unwrap(), vec!["v1.0.4".to_string()]); + } + #[tokio::test] async fn check_release_reports_outcomes_from_a_stubbed_source() { // Newer release available. diff --git a/crates/hephd/src/server.rs b/crates/hephd/src/server.rs index e6129d5..757a935 100644 --- a/crates/hephd/src/server.rs +++ b/crates/hephd/src/server.rs @@ -131,13 +131,15 @@ impl Daemon { return; }; let source = selfupdate::ForgeReleaseSource::new(self.ctx.http.clone()); + let installer: std::sync::Arc = + std::sync::Arc::new(selfupdate::CargoInstaller); tracing::info!( interval_secs = cfg.interval.as_secs(), current = heph_core::VERSION, "self-update enabled" ); tokio::spawn(async move { - selfupdate::run_poll_loop(source, cfg.interval, heph_core::VERSION).await; + selfupdate::run_poll_loop(source, installer, cfg.interval, heph_core::VERSION).await; }); }