generated from eblume/project-template
feat(heph): bake daemon mode/hub/oidc/self-update-interval into the service
All checks were successful
Build / validate (pull_request) Successful in 5m45s
All checks were successful
Build / validate (pull_request) Successful in 5m45s
`heph daemon start`/`restart` previously hardcoded `hephd --mode local` and only wired the bare `--self-update` bool — the poll interval and all spoke/hub sync config (`--hub-url`, `--http-addr`, `--oidc-*`) could not be set on the managed service without hand-editing the plist/unit (which a later start/restart would clobber). Generate the hephd arg vector from a DaemonConfig and add the corresponding `heph daemon start/restart` flags: --mode, --hub-url, --http-addr, --oidc-issuer, --oidc-audience, --oidc-client-id, and --self-update-interval-secs. Regenerating now reads the existing service file and preserves any flags not passed (start as well as restart), so a bare invocation never silently drops baked config. Closes the "pass through --self-update-interval-secs" and "bake hub/spoke config into the generated service" backlog tasks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c9bb2cbe64
commit
626c796e6c
4 changed files with 473 additions and 79 deletions
|
|
@ -4,12 +4,18 @@
|
|||
//! be shared by the CLI, TUI, and `heph.nvim` without any one of them owning its
|
||||
//! lifecycle. macOS uses a launchd **LaunchAgent**, Linux a **systemd user
|
||||
//! service**. All verbs are idempotent.
|
||||
//!
|
||||
//! The service generator bakes the daemon's runtime config — mode, sync hub,
|
||||
//! and OIDC — into the unit so a spoke/hub can run under the managed service
|
||||
//! instead of a hand-written plist/unit. Regenerating (`start`/`restart`)
|
||||
//! **preserves any config already baked into the on-disk file**, so a bare
|
||||
//! invocation never silently drops flags a previous one set.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Subcommand;
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use hephd::{default_db_path, default_socket_path};
|
||||
|
||||
|
|
@ -19,28 +25,106 @@ const LABEL: &str = "org.hephaestus.hephd";
|
|||
#[derive(Subcommand, Debug)]
|
||||
pub enum DaemonAction {
|
||||
/// Install (if needed) and start the daemon service.
|
||||
Start {
|
||||
/// Generate a service that runs with opt-in self-update enabled
|
||||
/// (default off). The service gets a PATH that can find cargo.
|
||||
#[arg(long)]
|
||||
self_update: bool,
|
||||
},
|
||||
Start(ServiceArgs),
|
||||
/// Stop the daemon now (it may restart at next login; use `uninstall` to
|
||||
/// stop it for good).
|
||||
Stop,
|
||||
/// Restart the daemon — run this after upgrading the binary. Preserves the
|
||||
/// existing self-update setting unless `--self-update` re-enables it.
|
||||
Restart {
|
||||
/// Force self-update on when regenerating the service definition.
|
||||
#[arg(long)]
|
||||
self_update: bool,
|
||||
},
|
||||
/// config already baked into the service file (pass flags to add/override).
|
||||
Restart(ServiceArgs),
|
||||
/// Show whether the service is installed and running.
|
||||
Status,
|
||||
/// Stop and remove the service entirely.
|
||||
Uninstall,
|
||||
}
|
||||
|
||||
/// Config flags baked into the generated service, shared by `start`/`restart`.
|
||||
/// Anything omitted falls back to what the on-disk service file already has, so
|
||||
/// regenerating is non-destructive.
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ServiceArgs {
|
||||
/// Runtime mode baked into the service (default `local`). Use `server` for a
|
||||
/// sync hub, `client` for an online-only proxy.
|
||||
#[arg(long, value_parser = ["local", "server", "client"])]
|
||||
mode: Option<String>,
|
||||
/// Hub to background-sync this replica's op-log with (makes it a spoke) —
|
||||
/// bakes `--hub-url`.
|
||||
#[arg(long)]
|
||||
hub_url: Option<String>,
|
||||
/// Hub HTTP listen address (server mode) — bakes `--http-addr`.
|
||||
#[arg(long)]
|
||||
http_addr: Option<String>,
|
||||
/// OIDC issuer used to verify (server) or obtain (spoke) hub tokens — bakes
|
||||
/// `--oidc-issuer`.
|
||||
#[arg(long)]
|
||||
oidc_issuer: Option<String>,
|
||||
/// OIDC audience hub tokens must carry (server mode) — bakes
|
||||
/// `--oidc-audience`.
|
||||
#[arg(long)]
|
||||
oidc_audience: Option<String>,
|
||||
/// OIDC client id this device authenticates as (spoke) — bakes
|
||||
/// `--oidc-client-id`.
|
||||
#[arg(long)]
|
||||
oidc_client_id: Option<String>,
|
||||
/// Generate a service that runs with opt-in self-update enabled (default
|
||||
/// off). The service gets a PATH that can find cargo.
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// The hephd flags the service generator bakes beyond the fixed `--db`/`--socket`.
|
||||
#[derive(Default, Clone, PartialEq, Debug)]
|
||||
struct DaemonConfig {
|
||||
mode: Option<String>,
|
||||
hub_url: Option<String>,
|
||||
http_addr: Option<String>,
|
||||
oidc_issuer: Option<String>,
|
||||
oidc_audience: Option<String>,
|
||||
oidc_client_id: Option<String>,
|
||||
self_update: bool,
|
||||
self_update_interval_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl ServiceArgs {
|
||||
fn to_config(&self) -> DaemonConfig {
|
||||
DaemonConfig {
|
||||
mode: self.mode.clone(),
|
||||
hub_url: self.hub_url.clone(),
|
||||
http_addr: self.http_addr.clone(),
|
||||
oidc_issuer: self.oidc_issuer.clone(),
|
||||
oidc_audience: self.oidc_audience.clone(),
|
||||
oidc_client_id: self.oidc_client_id.clone(),
|
||||
self_update: self.self_update,
|
||||
self_update_interval_secs: self.self_update_interval_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DaemonConfig {
|
||||
/// CLI-provided values win; anything omitted falls back to `base` (the flags
|
||||
/// already baked into the on-disk service file), so regenerating the service
|
||||
/// never drops config a previous invocation set. `self_update` is sticky —
|
||||
/// it stays on if either the CLI or the existing file enabled it.
|
||||
fn fill_from(self, base: DaemonConfig) -> DaemonConfig {
|
||||
DaemonConfig {
|
||||
mode: self.mode.or(base.mode),
|
||||
hub_url: self.hub_url.or(base.hub_url),
|
||||
http_addr: self.http_addr.or(base.http_addr),
|
||||
oidc_issuer: self.oidc_issuer.or(base.oidc_issuer),
|
||||
oidc_audience: self.oidc_audience.or(base.oidc_audience),
|
||||
oidc_client_id: self.oidc_client_id.or(base.oidc_client_id),
|
||||
self_update: self.self_update || base.self_update,
|
||||
self_update_interval_secs: self
|
||||
.self_update_interval_secs
|
||||
.or(base.self_update_interval_secs),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved locations the service definition needs.
|
||||
struct Paths {
|
||||
hephd: PathBuf,
|
||||
|
|
@ -114,6 +198,105 @@ pub fn run(action: &DaemonAction) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// hephd argument vector (pure — shared by both renderers and unit-tested)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/// The full `hephd …` argument vector the service runs, given the resolved paths
|
||||
/// and baked config. `--mode` defaults to `local`; the optional flags appear in
|
||||
/// a stable order so regenerating an unchanged config produces an identical file.
|
||||
fn hephd_args(hephd: &Path, db: &Path, socket: &Path, cfg: &DaemonConfig) -> Vec<String> {
|
||||
let mut a = vec![
|
||||
hephd.to_string_lossy().into_owned(),
|
||||
"--mode".into(),
|
||||
cfg.mode.clone().unwrap_or_else(|| "local".into()),
|
||||
"--db".into(),
|
||||
db.to_string_lossy().into_owned(),
|
||||
"--socket".into(),
|
||||
socket.to_string_lossy().into_owned(),
|
||||
];
|
||||
push_opt(&mut a, "--hub-url", &cfg.hub_url);
|
||||
push_opt(&mut a, "--http-addr", &cfg.http_addr);
|
||||
push_opt(&mut a, "--oidc-issuer", &cfg.oidc_issuer);
|
||||
push_opt(&mut a, "--oidc-audience", &cfg.oidc_audience);
|
||||
push_opt(&mut a, "--oidc-client-id", &cfg.oidc_client_id);
|
||||
// Interval is only meaningful with --self-update, so it's nested under it.
|
||||
if cfg.self_update {
|
||||
a.push("--self-update".into());
|
||||
if let Some(secs) = cfg.self_update_interval_secs {
|
||||
a.push("--self-update-interval-secs".into());
|
||||
a.push(secs.to_string());
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
fn push_opt(args: &mut Vec<String>, flag: &str, val: &Option<String>) {
|
||||
if let Some(v) = val {
|
||||
args.push(flag.to_string());
|
||||
args.push(v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `hephd` argument vector back into a [`DaemonConfig`] — the inverse of
|
||||
/// [`hephd_args`], used to recover config already baked into an on-disk service
|
||||
/// file. Unrecognized args (the binary path, `--db`, `--socket`) are ignored.
|
||||
fn parse_hephd_args(args: &[String]) -> DaemonConfig {
|
||||
let mut c = DaemonConfig::default();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let next = || args.get(i + 1).cloned();
|
||||
match args[i].as_str() {
|
||||
"--mode" => {
|
||||
c.mode = next();
|
||||
i += 2;
|
||||
}
|
||||
"--hub-url" => {
|
||||
c.hub_url = next();
|
||||
i += 2;
|
||||
}
|
||||
"--http-addr" => {
|
||||
c.http_addr = next();
|
||||
i += 2;
|
||||
}
|
||||
"--oidc-issuer" => {
|
||||
c.oidc_issuer = next();
|
||||
i += 2;
|
||||
}
|
||||
"--oidc-audience" => {
|
||||
c.oidc_audience = next();
|
||||
i += 2;
|
||||
}
|
||||
"--oidc-client-id" => {
|
||||
c.oidc_client_id = next();
|
||||
i += 2;
|
||||
}
|
||||
"--self-update" => {
|
||||
c.self_update = true;
|
||||
i += 1;
|
||||
}
|
||||
"--self-update-interval-secs" => {
|
||||
c.self_update_interval_secs = next().and_then(|s| s.parse().ok());
|
||||
i += 2;
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Recover the config baked into an existing service file (empty if absent).
|
||||
fn existing_config(path: &Path, mgr: &Manager) -> DaemonConfig {
|
||||
let Ok(s) = std::fs::read_to_string(path) else {
|
||||
return DaemonConfig::default();
|
||||
};
|
||||
let args = match mgr {
|
||||
Manager::Launchd => launchd_program_args(&s),
|
||||
Manager::Systemd => systemd_exec_args(&s),
|
||||
};
|
||||
parse_hephd_args(&args)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering (pure — unit-tested)
|
||||
// --------------------------------------------------------------------------
|
||||
|
|
@ -124,17 +307,22 @@ fn xml_escape(s: &str) -> String {
|
|||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update: bool) -> String {
|
||||
let arg = |p: &Path| xml_escape(&p.to_string_lossy());
|
||||
// Opt-in self-update: pass the flag, and give the service a PATH/HOME that
|
||||
// can find cargo + the toolchain (a LaunchAgent's default env can't), since
|
||||
// the apply path shells out to `cargo install`.
|
||||
let self_update_arg = if self_update {
|
||||
"\n <string>--self-update</string>".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let cargo_env = if self_update {
|
||||
fn xml_unescape(s: &str) -> String {
|
||||
s.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, cfg: &DaemonConfig) -> String {
|
||||
let args_xml = hephd_args(hephd, db, socket, cfg)
|
||||
.iter()
|
||||
.map(|a| format!(" <string>{}</string>", xml_escape(a)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
// Opt-in self-update needs a PATH/HOME that can find cargo + the toolchain
|
||||
// (a LaunchAgent's default env can't), since the apply path shells out to
|
||||
// `cargo install`.
|
||||
let cargo_env = if cfg.self_update {
|
||||
let (path, home) = cargo_env();
|
||||
format!(
|
||||
"\n <key>PATH</key>\n <string>{}</string>\n <key>HOME</key>\n <string>{}</string>",
|
||||
|
|
@ -153,13 +341,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update
|
|||
<string>{label}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{hephd}</string>
|
||||
<string>--mode</string>
|
||||
<string>local</string>
|
||||
<string>--db</string>
|
||||
<string>{db}</string>
|
||||
<string>--socket</string>
|
||||
<string>{socket}</string>{self_update_arg}
|
||||
{args_xml}
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
|
@ -181,10 +363,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path, self_update
|
|||
</plist>
|
||||
"#,
|
||||
label = LABEL,
|
||||
hephd = arg(hephd),
|
||||
db = arg(db),
|
||||
socket = arg(socket),
|
||||
log = arg(log),
|
||||
log = xml_escape(&log.to_string_lossy()),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -199,20 +378,34 @@ fn cargo_env() -> (String, String) {
|
|||
(path, home)
|
||||
}
|
||||
|
||||
/// Whether an already-installed service file opted into self-update — so
|
||||
/// `restart` (which regenerates the file) preserves the setting instead of
|
||||
/// silently turning it off.
|
||||
fn file_opts_into_self_update(path: &Path) -> bool {
|
||||
std::fs::read_to_string(path)
|
||||
.map(|s| s.contains("--self-update"))
|
||||
.unwrap_or(false)
|
||||
/// Extract the `ProgramArguments` strings from an existing launchd plist.
|
||||
fn launchd_program_args(plist: &str) -> Vec<String> {
|
||||
let Some(k) = plist.find("<key>ProgramArguments</key>") else {
|
||||
return vec![];
|
||||
};
|
||||
let rest = &plist[k..];
|
||||
let (Some(start), Some(end)) = (rest.find("<array>"), rest.find("</array>")) else {
|
||||
return vec![];
|
||||
};
|
||||
let block = &rest[start..end];
|
||||
let mut out = vec![];
|
||||
let mut cur = block;
|
||||
while let Some(o) = cur.find("<string>") {
|
||||
let after = &cur[o + "<string>".len()..];
|
||||
let Some(c) = after.find("</string>") else {
|
||||
break;
|
||||
};
|
||||
out.push(xml_unescape(&after[..c]));
|
||||
cur = &after[c + "</string>".len()..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn systemd_unit(hephd: &Path, db: &Path, socket: &Path, self_update: bool) -> String {
|
||||
// Opt-in self-update: pass the flag and give the unit a PATH/HOME that can
|
||||
// find cargo + the toolchain, since the apply path runs `cargo install`.
|
||||
let su_arg = if self_update { " --self-update" } else { "" };
|
||||
let cargo_env = if self_update {
|
||||
fn systemd_unit(hephd: &Path, db: &Path, socket: &Path, cfg: &DaemonConfig) -> String {
|
||||
let exec = hephd_args(hephd, db, socket, cfg).join(" ");
|
||||
// Opt-in self-update needs a PATH/HOME that can find cargo + the toolchain,
|
||||
// since the apply path runs `cargo install`.
|
||||
let cargo_env = if cfg.self_update {
|
||||
let (path, home) = cargo_env();
|
||||
format!("Environment=PATH={path}\nEnvironment=HOME={home}\n")
|
||||
} else {
|
||||
|
|
@ -224,19 +417,24 @@ fn systemd_unit(hephd: &Path, db: &Path, socket: &Path, self_update: bool) -> St
|
|||
After=default.target\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
ExecStart={hephd} --mode local --db {db} --socket {socket}{su_arg}\n\
|
||||
ExecStart={exec}\n\
|
||||
{cargo_env}\
|
||||
Restart=always\n\
|
||||
RestartSec=1\n\
|
||||
\n\
|
||||
[Install]\n\
|
||||
WantedBy=default.target\n",
|
||||
hephd = hephd.display(),
|
||||
db = db.display(),
|
||||
socket = socket.display(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract the `ExecStart=` argument vector from an existing systemd unit.
|
||||
fn systemd_exec_args(unit: &str) -> Vec<String> {
|
||||
unit.lines()
|
||||
.find_map(|l| l.strip_prefix("ExecStart="))
|
||||
.map(|rest| rest.split_whitespace().map(str::to_string).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
|
@ -303,10 +501,13 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
|||
let target = format!("gui/{uid}/{LABEL}");
|
||||
|
||||
match action {
|
||||
DaemonAction::Start { self_update } => {
|
||||
DaemonAction::Start(args) => {
|
||||
let cfg = args
|
||||
.to_config()
|
||||
.fill_from(existing_config(&plist, &Manager::Launchd));
|
||||
write_if_changed(
|
||||
&plist,
|
||||
&launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, *self_update),
|
||||
&launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, &cfg),
|
||||
)?;
|
||||
if launchd_loaded(&target) {
|
||||
println!("heph daemon already running ({LABEL}).");
|
||||
|
|
@ -322,11 +523,13 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
|||
let (_ok, _err) = run_cmd("launchctl", &["bootout", &target])?;
|
||||
println!("heph daemon stopped (still installed; `uninstall` to remove).");
|
||||
}
|
||||
DaemonAction::Restart { self_update } => {
|
||||
let su = *self_update || file_opts_into_self_update(&plist);
|
||||
DaemonAction::Restart(args) => {
|
||||
let cfg = args
|
||||
.to_config()
|
||||
.fill_from(existing_config(&plist, &Manager::Launchd));
|
||||
write_if_changed(
|
||||
&plist,
|
||||
&launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, su),
|
||||
&launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, &cfg),
|
||||
)?;
|
||||
let _ = run_cmd("launchctl", &["bootout", &target])?;
|
||||
let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?;
|
||||
|
|
@ -380,11 +583,11 @@ fn sc(args: &[&str]) -> Result<(bool, String)> {
|
|||
fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
||||
let unit = systemd_unit_path()?;
|
||||
match action {
|
||||
DaemonAction::Start { self_update } => {
|
||||
write_if_changed(
|
||||
&unit,
|
||||
&systemd_unit(&p.hephd, &p.db, &p.socket, *self_update),
|
||||
)?;
|
||||
DaemonAction::Start(args) => {
|
||||
let cfg = args
|
||||
.to_config()
|
||||
.fill_from(existing_config(&unit, &Manager::Systemd));
|
||||
write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket, &cfg))?;
|
||||
sc(&["daemon-reload"])?;
|
||||
let (ok, err) = sc(&["enable", "--now", UNIT])?;
|
||||
if !ok {
|
||||
|
|
@ -396,9 +599,11 @@ fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
|||
sc(&["stop", UNIT])?;
|
||||
println!("heph daemon stopped (still enabled; `uninstall` to remove).");
|
||||
}
|
||||
DaemonAction::Restart { self_update } => {
|
||||
let su = *self_update || file_opts_into_self_update(&unit);
|
||||
write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket, su))?;
|
||||
DaemonAction::Restart(args) => {
|
||||
let cfg = args
|
||||
.to_config()
|
||||
.fill_from(existing_config(&unit, &Manager::Systemd));
|
||||
write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket, &cfg))?;
|
||||
sc(&["daemon-reload"])?;
|
||||
let (ok, err) = sc(&["restart", UNIT])?;
|
||||
if !ok {
|
||||
|
|
@ -440,6 +645,18 @@ fn print_status(installed: bool, running: bool, p: &Paths, service_file: &Path)
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn spoke_cfg() -> DaemonConfig {
|
||||
DaemonConfig {
|
||||
mode: Some("local".into()),
|
||||
hub_url: Some("http://hub.example:8787".into()),
|
||||
oidc_issuer: Some("https://idp.example/o/heph/".into()),
|
||||
oidc_client_id: Some("heph".into()),
|
||||
self_update: true,
|
||||
self_update_interval_secs: Some(600),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launchd_plist_has_label_args_and_paths() {
|
||||
let plist = launchd_plist(
|
||||
|
|
@ -447,19 +664,21 @@ mod tests {
|
|||
Path::new("/home/e/.local/share/heph/heph.db"),
|
||||
Path::new("/tmp/heph/hephd.sock"),
|
||||
Path::new("/home/e/.local/share/heph/hephd.log"),
|
||||
false,
|
||||
&DaemonConfig::default(),
|
||||
);
|
||||
assert!(plist.contains("<string>org.hephaestus.hephd</string>"));
|
||||
assert!(plist.contains("<string>/usr/local/bin/hephd</string>"));
|
||||
assert!(plist.contains("<string>--mode</string>"));
|
||||
assert!(plist.contains("<string>local</string>"));
|
||||
assert!(plist.contains("<string>/home/e/.local/share/heph/heph.db</string>"));
|
||||
assert!(plist.contains("<string>/tmp/heph/hephd.sock</string>"));
|
||||
assert!(plist.contains("<key>RunAtLoad</key>"));
|
||||
assert!(plist.contains("<key>KeepAlive</key>"));
|
||||
assert!(plist.contains("hephd.log"));
|
||||
// Default (no self-update): no flag, no cargo PATH baked in.
|
||||
// Default (no self-update, no spoke/hub config): none of those flags.
|
||||
assert!(!plist.contains("--self-update"));
|
||||
assert!(!plist.contains(".cargo/bin"));
|
||||
assert!(!plist.contains("--hub-url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -469,12 +688,64 @@ mod tests {
|
|||
Path::new("/db"),
|
||||
Path::new("/sock"),
|
||||
Path::new("/log"),
|
||||
true,
|
||||
&DaemonConfig {
|
||||
self_update: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(plist.contains("<string>--self-update</string>"));
|
||||
assert!(plist.contains("<key>PATH</key>"));
|
||||
assert!(plist.contains(".cargo/bin"));
|
||||
assert!(plist.contains("<key>HOME</key>"));
|
||||
// No interval given → no interval flag.
|
||||
assert!(!plist.contains("--self-update-interval-secs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launchd_plist_self_update_interval_is_baked_under_self_update() {
|
||||
let with = launchd_plist(
|
||||
Path::new("/hephd"),
|
||||
Path::new("/db"),
|
||||
Path::new("/sock"),
|
||||
Path::new("/log"),
|
||||
&DaemonConfig {
|
||||
self_update: true,
|
||||
self_update_interval_secs: Some(3600),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(with.contains("<string>--self-update-interval-secs</string>"));
|
||||
assert!(with.contains("<string>3600</string>"));
|
||||
// Interval is meaningless without --self-update, so it's not emitted.
|
||||
let without = launchd_plist(
|
||||
Path::new("/hephd"),
|
||||
Path::new("/db"),
|
||||
Path::new("/sock"),
|
||||
Path::new("/log"),
|
||||
&DaemonConfig {
|
||||
self_update: false,
|
||||
self_update_interval_secs: Some(3600),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(!without.contains("--self-update-interval-secs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launchd_plist_bakes_spoke_config() {
|
||||
let plist = launchd_plist(
|
||||
Path::new("/hephd"),
|
||||
Path::new("/db"),
|
||||
Path::new("/sock"),
|
||||
Path::new("/log"),
|
||||
&spoke_cfg(),
|
||||
);
|
||||
assert!(plist.contains("<string>--hub-url</string>"));
|
||||
assert!(plist.contains("<string>http://hub.example:8787</string>"));
|
||||
assert!(plist.contains("<string>--oidc-issuer</string>"));
|
||||
assert!(plist.contains("<string>https://idp.example/o/heph/</string>"));
|
||||
assert!(plist.contains("<string>--oidc-client-id</string>"));
|
||||
assert!(plist.contains("<string>heph</string>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -483,7 +754,7 @@ mod tests {
|
|||
Path::new("/usr/local/bin/hephd"),
|
||||
Path::new("/home/e/.local/share/heph/heph.db"),
|
||||
Path::new("/run/user/1000/heph/hephd.sock"),
|
||||
false,
|
||||
&DaemonConfig::default(),
|
||||
);
|
||||
assert!(unit.contains(
|
||||
"ExecStart=/usr/local/bin/hephd --mode local \
|
||||
|
|
@ -507,17 +778,96 @@ mod tests {
|
|||
Path::new("/usr/local/bin/hephd"),
|
||||
Path::new("/db"),
|
||||
Path::new("/sock"),
|
||||
true,
|
||||
&DaemonConfig {
|
||||
self_update: true,
|
||||
self_update_interval_secs: Some(3600),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(unit.contains("--self-update"));
|
||||
assert!(unit.contains("--self-update --self-update-interval-secs 3600"));
|
||||
assert!(unit.contains("Environment=PATH="));
|
||||
assert!(unit.contains(".cargo/bin"));
|
||||
assert!(unit.contains("Environment=HOME="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_escape_escapes_markup() {
|
||||
fn systemd_unit_bakes_hub_config() {
|
||||
let unit = systemd_unit(
|
||||
Path::new("/hephd"),
|
||||
Path::new("/db"),
|
||||
Path::new("/sock"),
|
||||
&DaemonConfig {
|
||||
mode: Some("server".into()),
|
||||
http_addr: Some("0.0.0.0:8787".into()),
|
||||
oidc_issuer: Some("https://idp.example/o/heph/".into()),
|
||||
oidc_audience: Some("heph".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(unit.contains("--mode server"));
|
||||
assert!(unit.contains("--http-addr 0.0.0.0:8787"));
|
||||
assert!(unit.contains("--oidc-issuer https://idp.example/o/heph/"));
|
||||
assert!(unit.contains("--oidc-audience heph"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launchd_config_round_trips_through_the_plist() {
|
||||
let cfg = spoke_cfg();
|
||||
let plist = launchd_plist(
|
||||
Path::new("/hephd"),
|
||||
Path::new("/db"),
|
||||
Path::new("/sock"),
|
||||
Path::new("/log"),
|
||||
&cfg,
|
||||
);
|
||||
let parsed = parse_hephd_args(&launchd_program_args(&plist));
|
||||
assert_eq!(parsed, cfg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn systemd_config_round_trips_through_the_unit() {
|
||||
let cfg = DaemonConfig {
|
||||
mode: Some("server".into()),
|
||||
http_addr: Some("0.0.0.0:8787".into()),
|
||||
oidc_issuer: Some("https://idp.example/o/heph/".into()),
|
||||
oidc_audience: Some("heph".into()),
|
||||
self_update: true,
|
||||
self_update_interval_secs: Some(600),
|
||||
..Default::default()
|
||||
};
|
||||
let unit = systemd_unit(
|
||||
Path::new("/hephd"),
|
||||
Path::new("/db"),
|
||||
Path::new("/sock"),
|
||||
&cfg,
|
||||
);
|
||||
let parsed = parse_hephd_args(&systemd_exec_args(&unit));
|
||||
assert_eq!(parsed, cfg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_from_preserves_existing_and_lets_cli_override() {
|
||||
let existing = spoke_cfg();
|
||||
// A bare invocation (no flags) preserves everything baked in the file.
|
||||
assert_eq!(
|
||||
DaemonConfig::default().fill_from(existing.clone()),
|
||||
existing
|
||||
);
|
||||
// A CLI-provided value overrides; self_update stays sticky.
|
||||
let overridden = DaemonConfig {
|
||||
self_update_interval_secs: Some(60),
|
||||
..Default::default()
|
||||
}
|
||||
.fill_from(existing.clone());
|
||||
assert_eq!(overridden.self_update_interval_secs, Some(60));
|
||||
assert_eq!(overridden.hub_url, existing.hub_url);
|
||||
assert!(overridden.self_update);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_escape_round_trips() {
|
||||
assert_eq!(xml_escape("a & b < c > d"), "a & b < c > d");
|
||||
assert_eq!(xml_unescape("a & b < c > d"), "a & b < c > d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
1
docs/changelog.d/daemon-self-update-interval.feature.md
Normal file
1
docs/changelog.d/daemon-self-update-interval.feature.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
`heph daemon start`/`restart` can now bake the daemon's full runtime config into the managed service — `--mode`, `--hub-url`, `--http-addr`, `--oidc-issuer`/`--oidc-audience`/`--oidc-client-id`, and `--self-update-interval-secs` (previously only the bare `--self-update` bool was wired). Regenerating preserves whatever is already baked into the on-disk plist/unit, so a bare `start`/`restart` no longer silently drops spoke/hub or self-update config.
|
||||
|
|
@ -36,14 +36,47 @@ when it's already stopped is fine.
|
|||
> exits cleanly to hand off to the new binary) wouldn't come back on Linux. Run
|
||||
> `heph daemon restart` once (it regenerates the unit) to pick up `Restart=always`.
|
||||
|
||||
Either way it runs `hephd --mode local` against the default store
|
||||
By default it runs `hephd --mode local` against the default store
|
||||
(`~/.local/share/heph/heph.db`) and socket, with logs at
|
||||
`~/.local/share/heph/hephd.log`.
|
||||
`~/.local/share/heph/hephd.log`. Pass flags to `start`/`restart` to bake a
|
||||
different runtime config into the service (see below).
|
||||
|
||||
> **`stop` vs `uninstall`:** `stop` halts the daemon now, but the service is
|
||||
> still installed, so on macOS it starts again at next login. Use `uninstall`
|
||||
> to stop it persistently.
|
||||
|
||||
## Baking sync config (spoke / hub)
|
||||
|
||||
By default the service runs a standalone `--mode local` daemon. To make the
|
||||
managed service a **spoke** (background-syncs to a hub) or a **hub** (`--mode
|
||||
server`), pass the corresponding `hephd` flags to `start` (or `restart`) — they
|
||||
get baked into the generated plist/unit:
|
||||
|
||||
```bash
|
||||
# Spoke: sync to a hub, authenticating with OIDC
|
||||
heph daemon start \
|
||||
--hub-url http://hub.example:8787 \
|
||||
--oidc-issuer https://idp.example/application/o/heph/ \
|
||||
--oidc-client-id heph
|
||||
|
||||
# Hub: expose the authenticated sync endpoint
|
||||
heph daemon start --mode server \
|
||||
--http-addr 0.0.0.0:8787 \
|
||||
--oidc-issuer https://idp.example/application/o/heph/ \
|
||||
--oidc-audience heph
|
||||
```
|
||||
|
||||
Bakeable flags: `--mode`, `--hub-url`, `--http-addr`, `--oidc-issuer`,
|
||||
`--oidc-audience`, `--oidc-client-id`, `--self-update`,
|
||||
`--self-update-interval-secs`. **Regenerating preserves what's already baked
|
||||
in** — `start`/`restart` read the existing service file and carry over any flags
|
||||
you don't pass, so a bare `heph daemon restart` never drops your spoke/hub or
|
||||
self-update config. Pass a flag again to add or override it.
|
||||
|
||||
> Spoke sync is HTTP-only today (`hephd`'s sync client doesn't speak HTTPS) — a
|
||||
> `--hub-url` over the tailnet or behind a TLS-terminating proxy is the usual
|
||||
> setup.
|
||||
|
||||
## After upgrading
|
||||
|
||||
When you rebuild/reinstall (`cargo install … --force`), the running daemon is
|
||||
|
|
@ -59,9 +92,11 @@ heph daemon restart
|
|||
service that polls the forge for newer releases and, when one appears, rebuilds
|
||||
via `cargo install` (anonymous HTTPS clone of the public repo — no credentials)
|
||||
and restarts onto the new binary. It is **off by default**; the generated
|
||||
service also gets a `PATH` that can find cargo. `heph daemon restart` preserves
|
||||
the setting (pass `--self-update` again to turn it on later). Requires the Rust
|
||||
toolchain (`cargo`) installed for the service user.
|
||||
service also gets a `PATH` that can find cargo. Override the 6h poll cadence with
|
||||
`--self-update-interval-secs <secs>`. Both `start` and `restart` preserve an
|
||||
already-baked self-update setting (and its interval), so a bare invocation won't
|
||||
silently disable it — pass `--self-update` again only to turn it on later.
|
||||
Requires the Rust toolchain (`cargo`) installed for the service user.
|
||||
|
||||
## Development isolation
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,17 @@ heph daemon start --self-update
|
|||
```
|
||||
|
||||
That generates a launchd/systemd service that runs `hephd --self-update` and
|
||||
gives it a `PATH` that can find `cargo`. `heph daemon restart` preserves the
|
||||
setting (pass `--self-update` again to turn it on later). To run the daemon
|
||||
directly instead:
|
||||
gives it a `PATH` that can find `cargo`. Override the 6h poll cadence with
|
||||
`--self-update-interval-secs <secs>`:
|
||||
|
||||
```bash
|
||||
heph daemon start --self-update # default: poll every 6h
|
||||
heph daemon start --self-update --self-update-interval-secs 3600
|
||||
```
|
||||
|
||||
Both `start` and `restart` preserve an already-baked setting (the flag and its
|
||||
interval), so a bare invocation won't silently disable it — pass `--self-update`
|
||||
again only to turn it on later. To run the daemon directly instead:
|
||||
|
||||
```bash
|
||||
hephd --self-update # default: poll every 6h
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue