C2(hephd-self-update): impl cargo-install-from-tag (injectable Installer)

Add an Installer trait + CargoInstaller (runs cargo install --locked
--git <ssh> --tag <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) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-04 13:52:23 -07:00
commit 3fab637353
2 changed files with 107 additions and 9 deletions

View file

@ -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<S: ReleaseSource>(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<S: ReleaseSource>(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 <ssh> --tag <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<dyn Installer>, 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<S: ReleaseSource>(
source: S,
installer: Arc<dyn Installer>,
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<Vec<String>>,
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.

View file

@ -131,13 +131,15 @@ impl Daemon {
return;
};
let source = selfupdate::ForgeReleaseSource::new(self.ctx.http.clone());
let installer: std::sync::Arc<dyn selfupdate::Installer> =
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;
});
}