generated from eblume/project-template
C2: hephd self-update (Mikado plan — cards for review) #7
3 changed files with 156 additions and 25 deletions
C2(hephd-self-update): impl service-env-forge-access (public HTTPS, cargo on PATH)
The repo is public, so self-update needs no credentials: cargo install --git is a plain anonymous clone (NOT the access-restricted Forgejo cargo registry, which is what required forge.ops.eblu.me). Point INSTALL_GIT_URL and the releases poll at the canonical public host over HTTPS — verified end-to-end (cargo install --git https://forge.eblu.me/... --tag v1.0.3 builds a working hephd with zero auth). Make the headless service able to run the apply path: 'heph daemon start --self-update' (default off) generates a launchd/systemd service that passes --self-update and bakes a PATH (incl ~/.cargo/bin) + HOME so the minimal service env can find cargo. restart preserves the setting. Default (no flag) services are byte-identical to before. Template + URL behavior covered by unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
commit
59822d7257
|
|
@ -19,12 +19,22 @@ const LABEL: &str = "org.hephaestus.hephd";
|
|||
#[derive(Subcommand, Debug)]
|
||||
pub enum DaemonAction {
|
||||
/// Install (if needed) and start the daemon service.
|
||||
Start,
|
||||
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,
|
||||
},
|
||||
/// 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.
|
||||
Restart,
|
||||
/// 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,
|
||||
},
|
||||
/// Show whether the service is installed and running.
|
||||
Status,
|
||||
/// Stop and remove the service entirely.
|
||||
|
|
@ -114,8 +124,26 @@ fn xml_escape(s: &str) -> String {
|
|||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String {
|
||||
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 {
|
||||
let (path, home) = cargo_env();
|
||||
format!(
|
||||
"\n <key>PATH</key>\n <string>{}</string>\n <key>HOME</key>\n <string>{}</string>",
|
||||
xml_escape(&path),
|
||||
xml_escape(&home),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
|
|
@ -131,7 +159,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String {
|
|||
<string>--db</string>
|
||||
<string>{db}</string>
|
||||
<string>--socket</string>
|
||||
<string>{socket}</string>
|
||||
<string>{socket}</string>{self_update_arg}
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
|
@ -143,7 +171,7 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String {
|
|||
Aqua session as a LaunchAgent, so its child gets the GUI/hotkey it
|
||||
needs. Opt-in here (not in dev/test runs, which never set it). -->
|
||||
<key>HEPH_QUICKADD</key>
|
||||
<string>1</string>
|
||||
<string>1</string>{cargo_env}
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{log}</string>
|
||||
|
|
@ -160,14 +188,44 @@ fn launchd_plist(hephd: &Path, db: &Path, socket: &Path, log: &Path) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
fn systemd_unit(hephd: &Path, db: &Path, socket: &Path) -> String {
|
||||
/// A `PATH`/`HOME` pair for a service that must run `cargo install`. Service
|
||||
/// managers start with a minimal environment, so we prepend `~/.cargo/bin` (which
|
||||
/// holds cargo and the rustup toolchain shims) to the usual locations and pin
|
||||
/// `HOME`, which cargo needs for its registry/cache.
|
||||
fn cargo_env() -> (String, String) {
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let path =
|
||||
format!("{home}/.cargo/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin");
|
||||
(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)
|
||||
}
|
||||
|
||||
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 {
|
||||
let (path, home) = cargo_env();
|
||||
format!("Environment=PATH={path}\nEnvironment=HOME={home}\n")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"[Unit]\n\
|
||||
Description=heph daemon (hephd)\n\
|
||||
After=default.target\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
ExecStart={hephd} --mode local --db {db} --socket {socket}\n\
|
||||
ExecStart={hephd} --mode local --db {db} --socket {socket}{su_arg}\n\
|
||||
{cargo_env}\
|
||||
Restart=always\n\
|
||||
RestartSec=1\n\
|
||||
\n\
|
||||
|
|
@ -245,8 +303,11 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
|||
let target = format!("gui/{uid}/{LABEL}");
|
||||
|
||||
match action {
|
||||
DaemonAction::Start => {
|
||||
write_if_changed(&plist, &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log))?;
|
||||
DaemonAction::Start { self_update } => {
|
||||
write_if_changed(
|
||||
&plist,
|
||||
&launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, *self_update),
|
||||
)?;
|
||||
if launchd_loaded(&target) {
|
||||
println!("heph daemon already running ({LABEL}).");
|
||||
} else {
|
||||
|
|
@ -261,8 +322,12 @@ 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 => {
|
||||
write_if_changed(&plist, &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log))?;
|
||||
DaemonAction::Restart { self_update } => {
|
||||
let su = *self_update || file_opts_into_self_update(&plist);
|
||||
write_if_changed(
|
||||
&plist,
|
||||
&launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, su),
|
||||
)?;
|
||||
let _ = run_cmd("launchctl", &["bootout", &target])?;
|
||||
let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?;
|
||||
if !ok {
|
||||
|
|
@ -315,8 +380,11 @@ fn sc(args: &[&str]) -> Result<(bool, String)> {
|
|||
fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
||||
let unit = systemd_unit_path()?;
|
||||
match action {
|
||||
DaemonAction::Start => {
|
||||
write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket))?;
|
||||
DaemonAction::Start { self_update } => {
|
||||
write_if_changed(
|
||||
&unit,
|
||||
&systemd_unit(&p.hephd, &p.db, &p.socket, *self_update),
|
||||
)?;
|
||||
sc(&["daemon-reload"])?;
|
||||
let (ok, err) = sc(&["enable", "--now", UNIT])?;
|
||||
if !ok {
|
||||
|
|
@ -328,8 +396,9 @@ fn systemd(action: &DaemonAction, p: &Paths) -> Result<()> {
|
|||
sc(&["stop", UNIT])?;
|
||||
println!("heph daemon stopped (still enabled; `uninstall` to remove).");
|
||||
}
|
||||
DaemonAction::Restart => {
|
||||
write_if_changed(&unit, &systemd_unit(&p.hephd, &p.db, &p.socket))?;
|
||||
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))?;
|
||||
sc(&["daemon-reload"])?;
|
||||
let (ok, err) = sc(&["restart", UNIT])?;
|
||||
if !ok {
|
||||
|
|
@ -378,6 +447,7 @@ 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,
|
||||
);
|
||||
assert!(plist.contains("<string>org.hephaestus.hephd</string>"));
|
||||
assert!(plist.contains("<string>/usr/local/bin/hephd</string>"));
|
||||
|
|
@ -387,6 +457,24 @@ mod tests {
|
|||
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.
|
||||
assert!(!plist.contains("--self-update"));
|
||||
assert!(!plist.contains(".cargo/bin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launchd_plist_self_update_adds_flag_and_cargo_path() {
|
||||
let plist = launchd_plist(
|
||||
Path::new("/usr/local/bin/hephd"),
|
||||
Path::new("/db"),
|
||||
Path::new("/sock"),
|
||||
Path::new("/log"),
|
||||
true,
|
||||
);
|
||||
assert!(plist.contains("<string>--self-update</string>"));
|
||||
assert!(plist.contains("<key>PATH</key>"));
|
||||
assert!(plist.contains(".cargo/bin"));
|
||||
assert!(plist.contains("<key>HOME</key>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -395,6 +483,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,
|
||||
);
|
||||
assert!(unit.contains(
|
||||
"ExecStart=/usr/local/bin/hephd --mode local \
|
||||
|
|
@ -407,6 +496,23 @@ mod tests {
|
|||
assert!(!unit.contains("Restart=on-failure"));
|
||||
assert!(unit.contains("RestartSec="));
|
||||
assert!(unit.contains("WantedBy=default.target"));
|
||||
// Default (no self-update): no flag, no baked env.
|
||||
assert!(!unit.contains("--self-update"));
|
||||
assert!(!unit.contains("Environment=PATH="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn systemd_unit_self_update_adds_flag_and_env() {
|
||||
let unit = systemd_unit(
|
||||
Path::new("/usr/local/bin/hephd"),
|
||||
Path::new("/db"),
|
||||
Path::new("/sock"),
|
||||
true,
|
||||
);
|
||||
assert!(unit.contains("--self-update"));
|
||||
assert!(unit.contains("Environment=PATH="));
|
||||
assert!(unit.contains(".cargo/bin"));
|
||||
assert!(unit.contains("Environment=HOME="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -32,11 +32,11 @@ impl SelfUpdateConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.ops.eblu.me/api/v1/repos/eblume/hephaestus/releases/latest";
|
||||
"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`
|
||||
|
|
@ -141,11 +141,12 @@ pub async fn check_release<S: ReleaseSource>(source: &S, current: &str) -> Check
|
|||
}
|
||||
}
|
||||
|
||||
/// The git SSH URL self-update installs from. The SSH host on port 2222 is the
|
||||
/// proven path: cargo rejects the HTTPS host over its canonical-name mismatch
|
||||
/// (`forge.ops.eblu.me` vs the advertised `forge.eblu.me`). See the
|
||||
/// `service-env-forge-access` card.
|
||||
pub const INSTALL_GIT_URL: &str = "ssh://forgejo@forge.ops.eblu.me:2222/eblume/hephaestus.git";
|
||||
/// 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.
|
||||
|
|
@ -284,6 +285,20 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
|
|
|
|||
|
|
@ -53,6 +53,16 @@ still the old binary until you restart it:
|
|||
heph daemon restart
|
||||
```
|
||||
|
||||
## Self-update (opt-in)
|
||||
|
||||
`hephd` can keep itself current: `heph daemon start --self-update` generates a
|
||||
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.
|
||||
|
||||
## Development isolation
|
||||
|
||||
`heph daemon` manages the **installed** daemon on the default paths. For in-repo
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue