hephaestus/crates/hephd/src/selfupdate.rs
Erich Blume fac39386d0
All checks were successful
Build / validate (push) Successful in 4m31s
fix: self-update poll uses ureq (reqwest has no TLS backend)
hephd's reqwest client is built default-features=false with no TLS
feature, so the self-update release poll's HTTPS GET always failed
('release check failed: requesting forge releases/latest') — the bug
never surfaced before because nothing in production used reqwest over
HTTPS (hub sync is plain http://). Switch the poll to ureq, which is
already a dependency and ships a rustls/ring TLS stack needing no system
libs (notably no cmake/aws-lc-sys, which would break the rust:bookworm CI
image). Verified end-to-end: a 0.0.0 build now detects v1.1.0, installs,
and restarts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:26:13 -07:00

423 lines
16 KiB
Rust

//! 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.
//!
//! 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};
/// Default poll cadence when `--self-update` is on and no interval is given.
pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
/// Configuration for the opt-in self-update mode. Its mere presence (the daemon
/// holds an `Option<SelfUpdateConfig>`) means the mode is enabled; absent ⇒ off.
#[derive(Clone, Debug)]
pub struct SelfUpdateConfig {
/// How often to poll the forge for a newer release.
pub interval: Duration,
}
impl SelfUpdateConfig {
/// Build a config, falling back to [`DEFAULT_INTERVAL`] when no override.
pub fn new(interval: Option<Duration>) -> Self {
Self {
interval: interval.unwrap_or(DEFAULT_INTERVAL),
}
}
}
/// The forge releases feed for this project — the latest tagged release. The
/// repo is public, so this is an unauthenticated GET on the canonical public
/// host.
pub const RELEASES_LATEST_URL: &str =
"https://forge.eblu.me/api/v1/repos/eblume/hephaestus/releases/latest";
/// Extract the bare `X.Y.Z` semver from a version string that may carry a build
/// suffix (`heph_core::VERSION` is e.g. `"1.0.3 (aa376b4)"`) or a leading `v`
/// (release tags are `v1.0.4`).
fn parse_version(s: &str) -> Result<semver::Version> {
let head = s
.trim()
.trim_start_matches('v')
.split_whitespace()
.next()
.unwrap_or("");
semver::Version::parse(head).with_context(|| format!("parsing version {s:?}"))
}
/// Whether `latest_tag` names a strictly newer release than `current` (the
/// running `heph_core::VERSION`). A malformed version on either side is an
/// error — never a silent "no update".
pub fn update_available(current: &str, latest_tag: &str) -> Result<bool> {
Ok(parse_version(latest_tag)? > parse_version(current)?)
}
/// Pull the `tag_name` out of a Forgejo/Gitea `releases/latest` response body.
/// Split out from the HTTP fetch so it can be tested against a sample payload.
pub fn parse_latest_tag(body: &str) -> Result<String> {
#[derive(serde::Deserialize)]
struct Release {
tag_name: String,
}
let rel: Release =
serde_json::from_str(body).context("parsing forge releases/latest response")?;
Ok(rel.tag_name)
}
/// Fetch the latest release tag from the forge over HTTPS, blocking. Uses
/// `ureq` (already a dependency, with a rustls/ring TLS backend that needs no
/// system libs) rather than the daemon's `reqwest` client, which is built
/// without TLS — the forge poll is the only production HTTPS-over-HTTP-client
/// path (hub sync is plain HTTP). Network/HTTP/JSON failures surface as `Err`.
pub fn fetch_latest_tag(url: &str) -> Result<String> {
let body = ureq::get(url)
.call()
.context("requesting forge releases/latest")?
.body_mut()
.read_to_string()
.context("reading forge releases/latest body")?;
parse_latest_tag(&body)
}
/// Where "the latest release tag" comes from. Injectable so the poll loop can
/// be exercised without hitting the network (real impl: [`ForgeReleaseSource`]).
pub trait ReleaseSource: Send + Sync + 'static {
fn latest_tag(&self) -> impl std::future::Future<Output = Result<String>> + Send;
}
/// The production source: the forge's `releases/latest` over HTTPS (via `ureq`).
pub struct ForgeReleaseSource {
url: String,
}
impl ForgeReleaseSource {
/// Source hitting [`RELEASES_LATEST_URL`].
pub fn new() -> Self {
Self {
url: RELEASES_LATEST_URL.to_string(),
}
}
}
impl Default for ForgeReleaseSource {
fn default() -> Self {
Self::new()
}
}
impl ReleaseSource for ForgeReleaseSource {
async fn latest_tag(&self) -> Result<String> {
// `ureq` is blocking; keep it off the async runtime.
let url = self.url.clone();
tokio::task::spawn_blocking(move || fetch_latest_tag(&url))
.await
.context("release-fetch task panicked")?
}
}
/// The result of one self-update check — kept separate from logging so it can be
/// asserted in tests.
#[derive(Debug, PartialEq, Eq)]
pub enum CheckOutcome {
/// The running version is at or ahead of the latest release.
UpToDate,
/// A strictly newer release exists, named by this tag (e.g. `v1.0.4`).
UpdateAvailable(String),
/// The check failed (forge unreachable, bad body, unparseable version).
Failed(String),
}
/// Run one check against `source`, comparing the latest tag to `current`. Never
/// returns `Err` — a failure is folded into [`CheckOutcome::Failed`] so the loop
/// keeps going (a flaky forge must never crash or stall the daemon).
pub async fn check_release<S: ReleaseSource>(source: &S, current: &str) -> CheckOutcome {
match source.latest_tag().await {
Ok(tag) => match update_available(current, &tag) {
Ok(true) => CheckOutcome::UpdateAvailable(tag),
Ok(false) => CheckOutcome::UpToDate,
Err(e) => CheckOutcome::Failed(e.to_string()),
},
Err(e) => CheckOutcome::Failed(e.to_string()),
}
}
/// The git URL self-update installs from. hephaestus is a **public** repo, and
/// `cargo install --git` is a plain anonymous git clone — *not* the Forgejo
/// cargo *registry* (that's access-restricted and needs `forge.ops.eblu.me`;
/// this is unrelated). So a credential-free HTTPS clone of the canonical public
/// host works from any device.
pub const INSTALL_GIT_URL: &str = "https://forge.eblu.me/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(())
}
}
/// Hands off to the freshly-installed binary. Injectable so the apply path is
/// testable without actually exiting the test process (real: [`ProcessRestarter`]).
pub trait Restarter: Send + Sync + 'static {
/// Restart onto the new binary. The production impl does not return.
fn restart(&self) -> Result<()>;
}
/// The production restarter: exit cleanly so the OS service manager (launchd
/// `KeepAlive` / systemd `Restart=always`) respawns the new binary. In-flight
/// RPC connections simply drop; clients reconnect (the nvim plugin already does).
pub struct ProcessRestarter;
impl Restarter for ProcessRestarter {
fn restart(&self) -> Result<()> {
tracing::info!("self-update: exiting to let the service manager start the new binary");
std::process::exit(0);
}
}
/// Apply a detected update: install the binaries for `tag`, then restart onto
/// them. The blocking install runs on the blocking pool so it never stalls the
/// async runtime; the restart only happens if the install succeeded.
pub async fn apply_update(
installer: Arc<dyn Installer>,
restarter: Arc<dyn Restarter>,
tag: &str,
) -> Result<()> {
let owned = tag.to_string();
tokio::task::spawn_blocking(move || installer.install(&owned))
.await
.context("self-update install task panicked")??;
tracing::info!(%tag, "self-update: installed; restarting into the new binary");
restarter.restart()
}
/// 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>,
restarter: Arc<dyn Restarter>,
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, applying");
// On success the restarter exits the process, so this only
// returns on failure — log it and keep polling.
if let Err(e) = apply_update(installer.clone(), restarter.clone(), &tag).await {
tracing::error!("self-update: 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}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A canned release source for deterministic loop/decision tests.
struct FakeSource(Result<String, &'static str>);
impl ReleaseSource for FakeSource {
async fn latest_tag(&self) -> Result<String> {
self.0.clone().map_err(|e| anyhow::anyhow!(e))
}
}
/// 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(())
}
}
/// Records whether a restart was requested (instead of exiting the process).
#[derive(Default)]
struct FakeRestarter {
restarted: std::sync::Mutex<bool>,
}
impl Restarter for FakeRestarter {
fn restart(&self) -> Result<()> {
*self.restarted.lock().unwrap() = true;
Ok(())
}
}
#[test]
fn install_and_release_urls_are_public_https_no_ssh() {
// hephaestus is public; cargo install --git is a plain clone (not the
// access-restricted Forgejo cargo registry), so no SSH / credentials.
for url in [INSTALL_GIT_URL, RELEASES_LATEST_URL] {
assert!(url.starts_with("https://"), "{url} must be HTTPS");
assert!(!url.contains("ssh://"), "{url} must not use SSH");
assert!(
url.contains("forge.eblu.me"),
"{url} should use the canonical public host"
);
}
}
#[tokio::test]
async fn apply_update_installs_then_restarts_on_success() {
let inst = Arc::new(FakeInstaller::default());
let restart = Arc::new(FakeRestarter::default());
apply_update(inst.clone(), restart.clone(), "v1.0.4")
.await
.unwrap();
assert_eq!(*inst.installed.lock().unwrap(), vec!["v1.0.4".to_string()]);
assert!(
*restart.restarted.lock().unwrap(),
"should restart on success"
);
}
#[tokio::test]
async fn apply_update_does_not_restart_when_install_fails() {
let inst = Arc::new(FakeInstaller {
fail: true,
..Default::default()
});
let restart = Arc::new(FakeRestarter::default());
assert!(apply_update(inst.clone(), restart.clone(), "v1.0.4")
.await
.is_err());
assert_eq!(*inst.installed.lock().unwrap(), vec!["v1.0.4".to_string()]);
assert!(
!*restart.restarted.lock().unwrap(),
"must NOT restart after a failed install"
);
}
#[tokio::test]
async fn check_release_reports_outcomes_from_a_stubbed_source() {
// Newer release available.
let s = FakeSource(Ok("v1.0.4".into()));
assert_eq!(
check_release(&s, "1.0.3 (sha)").await,
CheckOutcome::UpdateAvailable("v1.0.4".into())
);
// Already current.
let s = FakeSource(Ok("v1.0.3".into()));
assert_eq!(
check_release(&s, "1.0.3 (sha)").await,
CheckOutcome::UpToDate
);
// Fetch failure → folded into Failed, never a panic/Err.
let s = FakeSource(Err("forge unreachable"));
assert!(matches!(
check_release(&s, "1.0.3 (sha)").await,
CheckOutcome::Failed(_)
));
// Malformed tag → Failed.
let s = FakeSource(Ok("not-a-tag".into()));
assert!(matches!(
check_release(&s, "1.0.3 (sha)").await,
CheckOutcome::Failed(_)
));
}
#[test]
fn config_defaults_interval_and_honors_override() {
assert_eq!(SelfUpdateConfig::new(None).interval, DEFAULT_INTERVAL);
assert_eq!(
SelfUpdateConfig::new(Some(Duration::from_secs(900))).interval,
Duration::from_secs(900)
);
}
#[test]
fn update_available_compares_ignoring_build_suffix_and_v_prefix() {
// Running version carries a build-sha suffix; tags carry a `v`.
assert!(update_available("1.0.3 (aa376b4)", "v1.0.4").unwrap());
assert!(update_available("1.0.3 (aa376b4)", "v2.0.0").unwrap());
// Same version → no update (a dirty rebuild of the same tag isn't newer).
assert!(!update_available("1.0.3 (aa376b4-dirty)", "v1.0.3").unwrap());
// Older tag than running → no update.
assert!(!update_available("1.0.3", "v1.0.2").unwrap());
// Patch/minor/major ordering.
assert!(update_available("1.0.9", "v1.1.0").unwrap());
assert!(!update_available("1.1.0", "v1.0.9").unwrap());
}
#[test]
fn update_available_errors_on_malformed_version() {
assert!(update_available("not-a-version", "v1.0.4").is_err());
assert!(update_available("1.0.3", "vNope").is_err());
}
#[test]
fn parse_latest_tag_reads_tag_name_from_forge_body() {
// A trimmed sample of a Forgejo releases/latest payload.
let body = r#"{
"id": 42,
"tag_name": "v1.0.4",
"name": "Release v1.0.4",
"draft": false,
"prerelease": false
}"#;
assert_eq!(parse_latest_tag(body).unwrap(), "v1.0.4");
}
#[test]
fn parse_latest_tag_errors_on_unexpected_body() {
assert!(parse_latest_tag("{}").is_err());
assert!(parse_latest_tag("not json").is_err());
}
#[test]
fn end_to_end_body_to_decision() {
// Parse a release body, then decide against a fixed running version.
let tag = parse_latest_tag(r#"{"tag_name": "v1.0.4"}"#).unwrap();
assert!(update_available("1.0.3 (aa376b4)", &tag).unwrap());
let tag = parse_latest_tag(r#"{"tag_name": "v1.0.3"}"#).unwrap();
assert!(!update_available("1.0.3 (aa376b4)", &tag).unwrap());
}
}