generated from eblume/project-template
C2(hephd-self-update): impl self-update poll loop (notify-only)
Add a ReleaseSource trait (real ForgeReleaseSource over HTTP; injectable for tests), check_release() returning a CheckOutcome (UpToDate/UpdateAvailable/Failed) that never errors so a flaky forge can't stall the daemon, and run_poll_loop() that ticks on the configured interval and logs when a newer release is available. spawn_self_update_loop now spawns the real poller. Detection is unit-tested with a stubbed source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
35569b0649
commit
9fb200fe24
2 changed files with 114 additions and 4 deletions
|
|
@ -85,10 +85,116 @@ pub async fn fetch_latest_tag(http: &reqwest::Client, url: &str) -> Result<Strin
|
|||
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 HTTP.
|
||||
pub struct ForgeReleaseSource {
|
||||
http: reqwest::Client,
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl ForgeReleaseSource {
|
||||
/// Source backed by the daemon's shared client, hitting [`RELEASES_LATEST_URL`].
|
||||
pub fn new(http: reqwest::Client) -> Self {
|
||||
Self {
|
||||
http,
|
||||
url: RELEASES_LATEST_URL.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReleaseSource for ForgeReleaseSource {
|
||||
async fn latest_tag(&self) -> Result<String> {
|
||||
fetch_latest_tag(&self.http, &self.url).await
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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) {
|
||||
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")
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use heph_core::Store;
|
|||
|
||||
use crate::oauth::{self, TokenStore};
|
||||
use crate::rpc::{self, Request, Response, RpcError, INTERNAL_ERROR, PARSE_ERROR};
|
||||
use crate::selfupdate::SelfUpdateConfig;
|
||||
use crate::selfupdate::{self, SelfUpdateConfig};
|
||||
use crate::sync::{self, SharedStore};
|
||||
|
||||
/// How a spoke obtains the bearer token it presents to its hub (tech-spec §13).
|
||||
|
|
@ -116,18 +116,22 @@ impl Daemon {
|
|||
self.ctx.store.clone()
|
||||
}
|
||||
|
||||
/// If self-update is enabled, start its background poller. For now this only
|
||||
/// announces the mode at startup; the polling + apply cycle is wired in
|
||||
/// later self-update work. No-op when the mode is off.
|
||||
/// If self-update is enabled, spawn its background poller: every
|
||||
/// `cfg.interval` it checks the forge for a newer release and (for now) logs
|
||||
/// when one is available. No-op when the mode is off.
|
||||
pub fn spawn_self_update_loop(&self) {
|
||||
let Some(cfg) = self.ctx.self_update.clone() else {
|
||||
return;
|
||||
};
|
||||
let source = selfupdate::ForgeReleaseSource::new(self.ctx.http.clone());
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/// If this is a spoke (`hub_url` set), spawn a background task that syncs the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue