generated from eblume/project-template
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:
parent
fd76aa0b3a
commit
3fab637353
2 changed files with 107 additions and 9 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue