generated from eblume/project-template
All checks were successful
Build / validate (push) Successful in 4m31s
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>
423 lines
16 KiB
Rust
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());
|
|
}
|
|
}
|