diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs index 62df200..ad3517c 100644 --- a/crates/hephd/src/main.rs +++ b/crates/hephd/src/main.rs @@ -17,8 +17,8 @@ use tokio::net::{TcpListener, UnixListener}; use heph_core::LocalStore; use hephd::{ - default_db_path, default_socket_path, sync, Daemon, KeyringTokenStore, LockGuard, RemoteStore, - SystemClock, TokenStore, + default_db_path, default_socket_path, selfupdate::SelfUpdateConfig, sync, Daemon, + KeyringTokenStore, LockGuard, RemoteStore, SystemClock, TokenStore, }; /// How often a spoke background-syncs with its hub. @@ -77,6 +77,16 @@ struct Cli { /// --oidc-issuer, the device attaches a cached bearer token to hub requests. #[arg(long)] oidc_client_id: Option, + + /// Opt-in (default off): periodically poll the forge for a newer release and + /// auto-update this daemon. Off unless this flag is given. + #[arg(long)] + self_update: bool, + + /// Override the self-update poll interval, in seconds (default: 6h). Only + /// meaningful with --self-update. + #[arg(long)] + self_update_interval_secs: Option, } /// Build the spoke/client token source: a keyring store keyed by `account` (the @@ -112,6 +122,11 @@ async fn main() -> Result<()> { .with_context(|| format!("creating socket dir {}", parent.display()))?; } + // Opt-in self-update (default off): `Some` only when `--self-update` is set. + let self_update = cli + .self_update + .then(|| SelfUpdateConfig::new(cli.self_update_interval_secs.map(Duration::from_secs))); + // Build the daemon for the chosen mode. `local`/`server` own the file (and // hold its lock for the process's life); `client` keeps no replica. let (_lock, daemon) = match cli.mode { @@ -131,7 +146,10 @@ async fn main() -> Result<()> { } None => RemoteStore::new(&server_url), }; - (None, Daemon::new(store)) + ( + None, + Daemon::new(store).with_self_update(self_update.clone()), + ) } Mode::Local | Mode::Server => { let db = cli.db.clone().unwrap_or_else(default_db_path); @@ -147,7 +165,8 @@ async fn main() -> Result<()> { }); let daemon = Daemon::new(store) .with_hub(cli.hub_url.clone()) - .with_spoke_auth(spoke); + .with_spoke_auth(spoke) + .with_self_update(self_update.clone()); // server mode: expose the hub HTTP endpoint over the same store. if cli.mode == Mode::Server { @@ -190,6 +209,9 @@ async fn main() -> Result<()> { } }; + // Opt-in self-update poller (no-op unless --self-update); mode-agnostic. + daemon.spawn_self_update_loop(); + // Replace any stale socket from a previous run, then bind. if socket.exists() { std::fs::remove_file(&socket) diff --git a/crates/hephd/src/selfupdate.rs b/crates/hephd/src/selfupdate.rs index 336b035..122130c 100644 --- a/crates/hephd/src/selfupdate.rs +++ b/crates/hephd/src/selfupdate.rs @@ -6,8 +6,30 @@ //! 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. +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`) 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) -> Self { + Self { + interval: interval.unwrap_or(DEFAULT_INTERVAL), + } + } +} + /// The forge releases feed for this project — the latest tagged release. /// Uses the SSH-canonical host (`forge.ops.eblu.me`); see the /// `service-env-forge-access` card for the cargo/forge host caveat. @@ -67,6 +89,15 @@ pub async fn fetch_latest_tag(http: &reqwest::Client, url: &str) -> Result, + /// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card). + self_update: Option, } impl Ctx { @@ -76,6 +79,7 @@ impl Daemon { hub_url: None, http: reqwest::Client::new(), auth: None, + self_update: None, }, } } @@ -100,12 +104,32 @@ impl Daemon { self } + /// Enable opt-in self-update with the given config (`None` ⇒ stays off). + pub fn with_self_update(mut self, cfg: Option) -> Daemon { + self.ctx.self_update = cfg; + self + } + /// The shared store handle, for code that needs to reach the same store the /// daemon serves (the hub HTTP router and background sync, tech-spec §6.1). pub fn store(&self) -> SharedStore { 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. + pub fn spawn_self_update_loop(&self) { + let Some(cfg) = self.ctx.self_update.clone() else { + return; + }; + tracing::info!( + interval_secs = cfg.interval.as_secs(), + current = heph_core::VERSION, + "self-update enabled" + ); + } + /// If this is a spoke (`hub_url` set), spawn a background task that syncs the /// op-log with the hub every `interval` (attaching a bearer token when auth /// is configured). No-op otherwise.