C2(hephd-self-update): impl --self-update opt-in flag + config plumbing

Add --self-update (default off) and --self-update-interval-secs to the
hephd CLI, a SelfUpdateConfig (Some => enabled), and thread it into the
Daemon (with_self_update) for every mode. spawn_self_update_loop()
currently just announces the mode at startup ('self-update enabled')
so the opt-in is observable; the poll/apply cycle is wired in later
leaves. Omitting the flag leaves behaviour unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-04 13:40:16 -07:00
commit f6bcd50684
3 changed files with 81 additions and 4 deletions

View file

@ -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<String>,
/// 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<u64>,
}
/// 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)

View file

@ -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<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.
/// 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<Strin
mod tests {
use super::*;
#[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`.

View file

@ -21,6 +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::sync::{self, SharedStore};
/// How a spoke obtains the bearer token it presents to its hub (tech-spec §13).
@ -40,6 +41,8 @@ struct Ctx {
http: reqwest::Client,
/// Token source for authenticated sync (None ⇒ unauthenticated hub).
auth: Option<SpokeAuth>,
/// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card).
self_update: Option<SelfUpdateConfig>,
}
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<SelfUpdateConfig>) -> 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.