server/cli/src/systemd_verbs.rs
//! **The systemd flavour** (lane B4, A31 §7.5): `holger install …` and
//! `holger service …`, Holger registered with systemd on a plain Linux host. Behind
//! the `systemd` feature (in the default, the exe flavour); the container and the
//! appliance build without it and link none of this.
use super::*;
// Skíðblaðnir (skidbladnir) — the reusable airgap-install engine. holger owns its
// own service spec (its real CLI verb is `start --config`) and its own artifact
// paths (holger-named, mirroring nornir's layout); the engine owns the whole
// systemd lifecycle (dual scope, unit render, install/remove/status). holger does
// NOT hand-roll any systemd logic — it extends the helper.
pub(crate) use skidbladnir_systemd::systemd::{
install_systemd_scoped, remove_systemd_scoped, status_systemd_scoped, systemd_unit_scoped,
RestartPolicy, Scope, SystemdServiceSpec,
};
// The ship's single lifecycle seam: holger declares a backend-neutral `ServiceSpec`
// (WHAT it is) and hands it to `Lifecycle` (HOW it runs — install/start/stop/status/
// health), so holger carries the SAME lifecycle as nornir/dwarfs and hand-rolls no
// systemd. `AgentMode::Fat` marks holger as a warehouse-holding node (thin holger
// nodes would route to a fat peer — inherited from the ship for free).
pub(crate) use skidbladnir_systemd::service::{AgentMode, Lifecycle, ServiceSpec};
#[derive(Subcommand)]
pub(crate) enum InstallOp {
/// Install (or update — idempotent) the `holger.service` unit + start it.
/// Default scope follows privilege (`user` when non-root, `system` when root).
Add(SvcArgs),
/// Alias for `add` — the engine writes-or-overwrites the unit either way.
Update(SvcArgs),
/// Stop + remove the `holger.service` unit. Keeps the service user + data
/// unless `--purge` (clean slate: also erases the scope's data tree, and in
/// system scope the staged binary). `--purge` ALWAYS keeps the ssh deploy key
/// and the token; `--wipe-identity` is the explicit opt-in that destroys them.
Remove(SvcArgs),
/// Print whether `holger.service` is active.
Status(SvcArgs),
/// Render the unit file that WOULD be installed, then stop. No root (dry-run).
Render(SvcArgs),
}
/// Lifecycle verbs driven through the ship's `skidbladnir::service::Lifecycle`.
/// Each builds `holger_spec()` (the neutral `ServiceSpec`) and delegates — no
/// systemd is hand-rolled here.
#[derive(Subcommand)]
pub(crate) enum ServiceOp {
/// Render + write the `holger.service` unit, `daemon-reload`, enable at boot,
/// and start it now (`Lifecycle::install`). Stages the binary in system scope.
Install(SvcArgs),
/// Start the unit now (`Lifecycle::start`).
Start(SvcArgs),
/// Stop the unit (`Lifecycle::stop`).
Stop(SvcArgs),
/// Print whether the unit is active (`Lifecycle::status`).
Status(SvcArgs),
/// Actively probe the service's health (`Lifecycle::health` — with no explicit
/// probe declared it reports whether the unit is active).
Health(SvcArgs),
/// Stop + disable the unit and remove its unit file (`Lifecycle::uninstall`);
/// the service user + data are kept unless `--purge`, which erases the scope's
/// data tree while KEEPING the ssh deploy key + token (`--wipe-identity`
/// destroys those too).
Uninstall(SvcArgs),
}
/// Service scope for `holger-server install`. Defaults to `user` when not root and
/// `system` when root — the clap mirror of `skidbladnir_systemd::systemd::Scope`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum ScopeArg {
/// `systemctl --user` unit running as you, from `~/.cargo/bin`, state in
/// `~/.holger`. No root.
User,
/// System unit (root) running as the `holger` user, binary in `/usr/local/bin`,
/// state under `/home/holger/.holger`.
System,
}
impl ScopeArg {
pub(crate) fn to_engine(self) -> Scope {
match self {
ScopeArg::User => Scope::User,
ScopeArg::System => Scope::System,
}
}
}
/// Knobs for the rendered `holger.service`. Holger owns this spec + its paths; the
/// engine is generic. All paths default scope-aware (holger-named, mirroring
/// nornir) when omitted.
#[derive(Args)]
pub(crate) struct SvcArgs {
/// Service scope. `user` = a `systemctl --user` unit running as you (no root);
/// `system` = a root unit running as the `holger` user. Default: `user` when
/// not root, `system` when root.
#[arg(long, value_enum)]
pub(crate) scope: Option<ScopeArg>,
/// Binary `ExecStart=` runs. Default: this running executable — execed in place
/// (user scope) or staged to `/usr/local/bin/holger-server` (system scope).
#[arg(long)]
pub(crate) bin: Option<PathBuf>,
/// `--config` path the service starts with (Holger config is RON). Default:
/// `<data>/holger.ron`.
#[arg(long)]
pub(crate) config: Option<PathBuf>,
/// `WorkingDirectory=` + the service user's home (the data root). Default:
/// `~/.holger` (user scope) / `/home/holger/.holger` (system scope).
#[arg(long)]
pub(crate) data: Option<PathBuf>,
/// System user to create + run as (system scope only; default `holger`).
#[arg(long, default_value = "holger")]
pub(crate) user: String,
/// `remove` / `service uninstall` ONLY: NUKE THE DATA, KEEP THE KEYS. Wipes the
/// scope's data tree — `~/.holger` under user scope, `/home/holger` (which holds
/// `/home/holger/.holger`) under system scope: archives, audit log, autogen
/// config, caches and registry state. System scope also removes the staged
/// `/usr/local/bin/holger-server` binary.
///
/// The ssh deploy key (`.ssh`) and any stored token SURVIVE — always, in BOTH
/// scopes. The `holger` service account and its home are kept too, so the key
/// stays owned and a reinstall reuses it without re-registering anything on the
/// git remotes. Destroying the identity is a separate, explicit opt-in:
/// `--wipe-identity`. Mirrors `nornir install remove --purge`.
#[arg(long)]
pub(crate) purge: bool,
/// DESTRUCTIVE OPT-IN — only valid together with `--purge`: ALSO destroy this
/// node's IDENTITY, i.e. the `.ssh` deploy key and any stored token (and, in
/// system scope, the `holger` service account itself).
///
/// THE DEPLOY KEY REGISTERED ON THE GIT REMOTES WILL BE DESTROYED. It cannot be
/// recovered: a reinstall generates a NEW key that you must register on every
/// remote again before holger can pull. Without this flag `--purge` always keeps
/// the key and the token.
#[arg(long, requires = "purge")]
pub(crate) wipe_identity: bool,
}
/// The holger system user's home — mirrors nornir's `/home/nornir`.
pub(crate) const HOLGER_SYSTEM_HOME: &str = "/home/holger";
/// Resolve the data root for `scope` (holger-named, nornir-mirrored): `~/.holger`
/// in user scope, `/home/holger/.holger` in system scope. An explicit `--data`
/// overrides.
pub(crate) fn resolve_data(scope: Scope, a: &SvcArgs) -> Result<PathBuf> {
if let Some(d) = &a.data {
return Ok(d.clone());
}
let home: PathBuf = match scope {
Scope::User => std::env::var_os("HOME")
.map(PathBuf::from)
.context("$HOME is not set — needed to derive the user-scope data root")?,
Scope::System => PathBuf::from(HOLGER_SYSTEM_HOME),
};
Ok(home.join(".holger"))
}
/// **The config the unit starts with** (lane OX-HOLFLAV): the unit runs
/// `start --config <data>/holger.ron`, so the install lays that file when the data
/// root has none. Returns the path it laid, or `None` when it laid nothing.
///
/// The file is the first start's ([`super::first_start_config`]): two local
/// registries (`/cache`, `/sparring`) on loopback, and in the default build no
/// remote at all, so the installed service dials nothing. An existing file is the
/// operator's and is never touched; `--config` names the operator's own file, so
/// nothing is laid then. In system scope the engine's `ensure_system_user` gives
/// the data root (and this file) to the `holger` user right after.
pub(crate) fn lay_service_config(a: &SvcArgs, data: &Path) -> Result<Option<PathBuf>> {
if a.config.is_some() || data.join("holger.ron").exists() {
return Ok(None);
}
super::first_start_config(data).map(Some)
}
/// The staged system-scope binary — product, not identity, so `--purge` removes it.
pub(crate) const HOLGER_SYSTEM_BIN: &str = "/usr/local/bin/holger-server";
/// **IDENTITY, never data.** Direct children of a holger service tree that carry the
/// node's identity rather than its content: the `.ssh` git deploy key registered on
/// the private remotes, and any stored access token.
///
/// **The owner's law: uninstalling the software NUKES ALL DATA but KEEPS THE KEYS.**
/// `--purge` skips every name in this list, in both scopes, unconditionally. Only the
/// explicit `--wipe-identity` opt-in destroys them. This is holger's twin of the
/// `.ssh` skip list inside [`skidbladnir_systemd::systemd::purge_service_data_keep_keys`],
/// widened by the token entry that the shared helper's `.ssh`-only rule does not
/// cover — the DELETE decision itself is still the one shared authority
/// ([`skidbladnir_systemd::systemd::guarded_remove_dir_all`]), never re-implemented here.
pub(crate) const IDENTITY_ENTRIES: &[&str] = &[".ssh", "token", ".token", "holger.token"];
/// What a `--purge` actually did — collected first, printed second, so the CLI can
/// report HONESTLY (and stay silent about success when nothing was removed) and so a
/// test can assert on the outcome without scraping stdout.
#[derive(Default, Debug)]
pub(crate) struct PurgeReport {
/// Data paths erased (each through skidbladnir's guarded delete).
pub(crate) wiped: Vec<PathBuf>,
/// Identity paths deliberately left byte-intact (`.ssh`, token).
pub(crate) identity_kept: Vec<PathBuf>,
/// Identity paths destroyed — only ever non-empty under `--wipe-identity`.
pub(crate) identity_wiped: Vec<PathBuf>,
/// Paths the guard (or the filesystem) refused to remove.
pub(crate) failed: usize,
/// Anti-rollback floors (`holger-update-state` directories) that this purge
/// DESTROYED, named one by one. Collected before the erase because afterwards
/// there is nothing left to name. See [`ROLLBACK_STATE_DIR`].
pub(crate) rollback_floors_destroyed: Vec<PathBuf>,
}
/// The directory name the Secure Update Gateway keeps its anti-rollback markers in
/// (`server/cli/src/secure_update.rs`, `Store::state`).
pub(crate) const ROLLBACK_STATE_DIR: &str = "holger-update-state";
/// Find every anti-rollback floor under `root`, so a purge can NAME what it is
/// about to destroy.
///
/// ?27, answered 2026-09-20: `wipe` MAY erase the floor — it is data in the data
/// tree, and pretending otherwise would mean a purge that leaves state behind
/// under a name the operator did not ask to keep. What it may NOT do is erase it
/// in silence, and there are now two ends to that:
///
/// * here, the purge says which floors it destroyed, by path;
/// * and at the other end, `bundle import` REFUSES against an erased floor unless
/// an admin authorizes the first import (`holger bundle allow-first-import`, lane
/// HOL-CDS; `update_bundle::rollback::Floor`).
///
/// The second half is the load-bearing one. An announcement can be missed; a
/// refusal cannot. ⚠ The bundle's own CMS chain does not cover this: it is not
/// rollback-aware, so a correctly signed OLD bundle passes forever. The floor is
/// the only downgrade stop there is.
#[cfg(unix)]
pub(crate) fn find_rollback_floors(root: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(root) else {
return;
};
for entry in rd.flatten() {
let path = entry.path();
// Never follow a link out of the tree — the same rule the guarded delete
// applies, for the same reason.
if std::fs::symlink_metadata(&path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(true)
{
continue;
}
if !path.is_dir() {
continue;
}
if path.file_name().and_then(|n| n.to_str()) == Some(ROLLBACK_STATE_DIR) {
out.push(path);
continue; // the floor itself; no need to descend into it
}
find_rollback_floors(&path, out);
}
}
impl PurgeReport {
/// Did the purge actually erase anything? A purge that removed NOTHING must not
/// print a success line.
pub(crate) fn removed_anything(&self) -> bool {
!self.wiped.is_empty() || !self.identity_wiped.is_empty()
}
}
/// The roots a `--purge` may erase under `scope`, **scope-aware** — never a hardcoded
/// constant that is wrong for the scope actually in use:
///
/// * **user scope** — the data root alone (`~/.holger`, or an explicit `--data`). The
/// human's own `~/.ssh` sits OUTSIDE it and is never even enumerated.
/// * **system scope** — the `holger` service home `/home/holger`, which CONTAINS the
/// default data root `/home/holger/.holger`; wiping its children clears the data
/// while `/home/holger/.ssh` is skipped by name and the home itself is never
/// removed. An explicit `--data` outside that home is added as a second root.
pub(crate) fn purge_roots(scope: Scope, data: &Path) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
if scope == Scope::System {
roots.push(PathBuf::from(HOLGER_SYSTEM_HOME));
}
if !roots.iter().any(|r| data.starts_with(r)) {
roots.push(data.to_path_buf());
}
roots
}
/// Destroy ONE identity entry — reachable only via the explicit `--wipe-identity`
/// opt-in. The shared [`skidbladnir_systemd::systemd::guarded_remove_dir_all`] cannot be used
/// here BY DESIGN: it hard-refuses any `.ssh` component because key material is never
/// data. So the opt-in does the delete itself, under its own narrow guard — the target
/// must be a DIRECT child of the declared root, must carry an [`IDENTITY_ENTRIES`]
/// name, and is never followed through a symlink out of the tree.
#[cfg(unix)]
pub(crate) fn wipe_identity_entry(path: &Path, root: &Path) -> Result<()> {
if path.parent() != Some(root) {
anyhow::bail!(
"REFUSING to wipe {}: not a direct child of {}",
path.display(),
root.display()
);
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
if !IDENTITY_ENTRIES.contains(&name.as_str()) {
anyhow::bail!("REFUSING to wipe {}: not an identity entry", path.display());
}
let md = std::fs::symlink_metadata(path)
.with_context(|| format!("stat identity target {}", path.display()))?;
if md.file_type().is_symlink() {
anyhow::bail!(
"REFUSING to wipe {}: it is a symlink — never follow a link out of the tree",
path.display()
);
}
if md.is_dir() {
std::fs::remove_dir_all(path)
.with_context(|| format!("wipe identity dir {}", path.display()))
} else {
std::fs::remove_file(path).with_context(|| format!("wipe identity file {}", path.display()))
}
}
/// **The holger `--purge` data-erase: NUKE ALL DATA, KEEP THE KEYS.** Wipes every
/// direct child of `root` EXCEPT the [`IDENTITY_ENTRIES`], each erase routed through
/// [`skidbladnir_systemd::systemd::guarded_remove_dir_all`] — the ONE post-incident authority
/// that refuses any `.ssh` component, a real user's home, a system directory, a path
/// outside the declared `root`, and a symlink out of the tree. The same primitive
/// [`skidbladnir_systemd::systemd::purge_service_data_keep_keys`] delegates to; holger calls
/// it per entry only so the token joins `.ssh` on the keep list and so the report can
/// name what it touched.
///
/// `uid` is the account that OWNS the tree (its own home stays allowed by the guard).
/// `wipe_identity` flips the key + token from KEPT to DESTROYED — the explicit opt-in,
/// never the default. Best-effort: a refusal is reported, not fatal, and a missing
/// root is simply nothing to do.
#[cfg(unix)]
pub(crate) fn purge_tree_keep_identity(
root: &Path,
uid: u32,
wipe_identity: bool,
report: &mut PurgeReport,
) {
use skidbladnir_systemd::systemd::guarded_remove_dir_all;
let Ok(rd) = std::fs::read_dir(root) else {
return; // absent / unreadable ⇒ nothing to erase
};
let mut children: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
children.sort(); // deterministic order ⇒ deterministic output
for p in children {
let name = p
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
if IDENTITY_ENTRIES.contains(&name.as_str()) {
if !wipe_identity {
report.identity_kept.push(p); // KEEP THE KEYS — untouched, byte-intact
continue;
}
match wipe_identity_entry(&p, root) {
Ok(()) => report.identity_wiped.push(p),
Err(e) => {
eprintln!(
"holger: --wipe-identity left {} in place: {e:#}",
p.display()
);
report.failed += 1;
}
}
continue;
}
match guarded_remove_dir_all(&p, uid, Some(root)) {
Ok(()) => report.wiped.push(p),
Err(e) => {
eprintln!("holger: purge left {} in place: {e:#}", p.display());
report.failed += 1;
}
}
}
}
/// The uid that OWNS the tree being purged, so the guard treats that account's own
/// home as allowed instead of a foreign home to refuse: the `holger` service account
/// in system scope, the INVOKING human in user scope (whose `~/.holger` lives inside
/// his own `$HOME`).
#[cfg(unix)]
pub(crate) fn purge_owner_uid(scope: Scope, user: &str) -> u32 {
let name = match scope {
Scope::System => user.to_string(),
Scope::User => whoami_or(""),
};
skidbladnir_systemd::systemd::lookup_system_uid(&name).unwrap_or(0)
}
/// Clean-slate `remove --purge` / `service uninstall --purge`: erase holger's DATA in
/// the scope actually in use, and KEEP the ssh deploy key + token (the owner's law).
/// Under `wipe_identity` — the explicit opt-in — the key, the token and (system scope)
/// the service account go too.
///
/// System scope deliberately does NOT `rm -rf /home/holger` and does NOT remove the
/// `holger` account on a plain `--purge`: the home HOLDS `/home/holger/.ssh`, and the
/// account OWNS it. Destroying the identity is never a side effect of removing a
/// service account here — it happens only when asked for by name.
#[cfg(unix)]
pub(crate) fn purge_holger_data(
scope: Scope,
data: &Path,
user: &str,
wipe_identity: bool,
) -> PurgeReport {
let uid = purge_owner_uid(scope, user);
let mut report = PurgeReport::default();
let roots = purge_roots(scope, data);
// Name the anti-rollback floors BEFORE erasing them — afterwards there is
// nothing left to name (?27).
for root in &roots {
find_rollback_floors(root, &mut report.rollback_floors_destroyed);
}
for root in &roots {
purge_tree_keep_identity(root, uid, wipe_identity, &mut report);
}
// Only claim destruction for floors that are actually gone.
report.rollback_floors_destroyed.retain(|p| !p.exists());
// The data root itself, once emptied — but only when it is not the service home
// (which must survive to hold `.ssh`) and only when nothing identity-bearing is
// still inside it. Guarded like every other delete.
let data_root_now_empty = data != Path::new(HOLGER_SYSTEM_HOME)
&& std::fs::read_dir(data)
.map(|mut d| d.next().is_none())
.unwrap_or(false);
if data_root_now_empty
&& skidbladnir_systemd::systemd::guarded_remove_dir_all(data, uid, Some(data)).is_ok()
{
report.wiped.push(data.to_path_buf());
}
if scope == Scope::System {
// The staged binary is product, not identity.
if std::fs::remove_file(HOLGER_SYSTEM_BIN).is_ok() {
report.wiped.push(PathBuf::from(HOLGER_SYSTEM_BIN));
}
if wipe_identity {
// Only now — removing the account is what orphans the key, so it is part
// of the identity wipe, never of a data purge. `userdel` WITHOUT `-r`:
// the home stays, the key inside it has already been destroyed above.
// A32-14, a process by design: the host install's identity wipe removes the box's own account with the system's own tool (shadow and group files under its locks); a host verb, never in a box's serving path.
let status = std::process::Command::new("userdel").arg(user).status();
record_userdel(user, status, &mut report);
}
}
report
}
/// Record the outcome of `userdel <user>` in the purge report. Only an exit 0
/// claims the account gone; a non-zero exit or a `userdel` that could not be
/// started is logged and counted as a refusal, like every other path the purge
/// left in place.
pub(crate) fn record_userdel(
user: &str,
status: std::io::Result<std::process::ExitStatus>,
report: &mut PurgeReport,
) {
match status {
Ok(s) if s.success() => report
.identity_wiped
.push(PathBuf::from(format!("user `{user}`"))),
Ok(s) => {
eprintln!("holger: --wipe-identity left user `{user}` in place: userdel {s}");
report.failed += 1;
}
Err(e) => {
eprintln!(
"holger: --wipe-identity left user `{user}` in place: userdel could not run: {e}"
);
report.failed += 1;
}
}
}
/// Print exactly what the purge did — no more, no less. A purge that erased NOTHING
/// gets no success line; a purge that kept the identity says so out loud, and one that
/// destroyed it says THAT out loud.
pub(crate) fn print_purge_report(scope: Scope, data: &Path, wipe_identity: bool, r: &PurgeReport) {
if r.removed_anything() {
println!(
" purged : {} item(s) — holger data erased",
r.wiped.len()
);
for p in &r.wiped {
println!(" {}", p.display());
}
} else {
println!(
" purged : nothing — no holger data found under {} ({scope:?} scope)",
data.display()
);
}
if r.failed > 0 {
println!(
" refused : {} path(s) left in place (see the messages above)",
r.failed
);
}
if !r.rollback_floors_destroyed.is_empty() {
println!(
" FLOOR GONE : {} anti-rollback floor(s) DESTROYED",
r.rollback_floors_destroyed.len()
);
for p in &r.rollback_floors_destroyed {
println!(" {}", p.display());
}
println!(
" nothing now records which bundle sequences were accepted \
here. The next `holger-server bundle import` into this store REFUSES \
until an admin runs `bundle allow-first-import` for that bundle, because an absent floor admits an OLD \
bundle as readily as a new one and the bundle signature does not stop it."
);
}
if wipe_identity {
if r.identity_wiped.is_empty() {
println!(" identity : nothing to destroy — no ssh key or token was present");
} else {
println!(
" DESTROYED : {} identity item(s) — --wipe-identity",
r.identity_wiped.len()
);
for p in &r.identity_wiped {
println!(" {}", p.display());
}
println!(
" the deploy key registered on the git remotes is GONE; a \
reinstall needs a NEW key registered on every remote."
);
}
} else if r.identity_kept.is_empty() {
println!(" KEPT : ssh key + token — none were present to keep");
} else {
println!(
" KEPT : {} identity item(s) — byte-intact",
r.identity_kept.len()
);
for p in &r.identity_kept {
println!(" {}", p.display());
}
println!(
" the ssh deploy key + token SURVIVE a purge; a reinstall \
reuses them (pass --wipe-identity to destroy them too)."
);
}
}
/// Build Holger's own systemd spec for `scope`. Holger's real serve verb is
/// `start --config` (NOT `serve`), so it constructs the spec here and uses the
/// engine only to apply it — the right product/engine split. `exec_path` is the
/// final on-system binary path the unit's `ExecStart=` runs.
pub(crate) fn holger_service_spec(
a: &SvcArgs,
exec_path: &Path,
data: &Path,
) -> SystemdServiceSpec {
let data_s = data.to_string_lossy().into_owned();
let config = a.config.clone().unwrap_or_else(|| data.join("holger.ron"));
SystemdServiceSpec::new(
"holger",
"Holger artifact repository",
exec_path.to_string_lossy(),
&a.user, // ignored by the engine in user scope (no User=/Group= line)
&data_s,
)
.with_arg("start")
.with_arg("--config")
.with_arg(config.to_string_lossy())
.with_env("HOLGER_DATA", &data_s)
.with_restart(RestartPolicy::OnFailure)
}
/// Holger's backend-neutral **ship** spec (`skidbladnir::service::ServiceSpec`) —
/// the single source of truth the `Lifecycle` runs. Same shape as
/// `holger_service_spec` (real verb is `start --config`, data-path env, on-failure
/// restart), expressed once through the neutral seam so holger inherits the SAME
/// lifecycle nornir/dwarfs have. `scope` (system vs `--user`) rides on the spec;
/// `AgentMode::Fat` marks holger as a warehouse-holding node (a thin holger node
/// would route to a fat peer — inherited from the ship, no new lifecycle code).
pub(crate) fn holger_spec(a: &SvcArgs, scope: Scope, exec_path: &Path, data: &Path) -> ServiceSpec {
let data_s = data.to_string_lossy().into_owned();
let config = a.config.clone().unwrap_or_else(|| data.join("holger.ron"));
#[allow(unused_mut)]
let mut spec = ServiceSpec::new(
"holger",
"Holger artifact repository",
exec_path.to_string_lossy(),
)
.scope(scope)
.user(a.user.as_str()) // omitted by the renderer in user scope (runs as you)
.working_dir(data_s.as_str())
.arg("start")
.arg("--config")
.arg(config.to_string_lossy())
.env("HOLGER_DATA", data_s.as_str())
.restart(RestartPolicy::OnFailure)
.agent(AgentMode::Fat);
// THE declared data root — the ONE `Lifecycle::uninstall` reads. Without it the
// ship answers "declares NO data root … NOTHING was erased" and erases 0 items,
// leaving the erase to whatever bespoke branch the product runs afterwards.
//
// `data` is ALREADY holger's resolved data root (the same value the unit's
// `HOLGER_DATA=` and `WorkingDirectory=` carry), so the declaration reuses it
// rather than inventing a second ladder — the resolver and the eraser cannot
// disagree. One schema under either scope: the caller's home in user scope, the
// `holger` account's home in system scope.
#[cfg(unix)]
match skidbladnir_systemd::uninstall::DataRoot::from_env("HOLGER_DATA", data) {
Ok(dr) => spec = spec.data_root(&dr),
Err(e) => eprintln!("holger: {e} — uninstall would erase nothing; not declaring a root"),
}
spec
}
/// Atomically install `src` to `dst` with mode 0755 (temp-write + rename on the
/// same dir). Used to stage the server binary into `/usr/local/bin` for system
/// scope (the `holger` user can't exec from your `~/.cargo/bin` / a removable
/// drive → systemd 203/EXEC).
#[cfg(unix)]
pub(crate) fn install_binary(src: &Path, dst: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).ok();
}
let tmp = dst.with_extension("new");
std::fs::copy(src, &tmp)
.with_context(|| format!("copy {} -> {}", src.display(), tmp.display()))?;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
.with_context(|| format!("chmod 755 {}", tmp.display()))?;
std::fs::rename(&tmp, dst)
.with_context(|| format!("rename {} -> {}", tmp.display(), dst.display()))?;
Ok(())
}
/// The binary the unit should exec, plus any staging side effect:
/// - user scope: exec your `--bin` (or this executable) in place — a user can
/// exec from their own cargo dir, so no copy.
/// - system scope: stage the binary into `/usr/local/bin/holger-server` and exec
/// it there.
pub(crate) fn resolve_exec(scope: Scope, a: &SvcArgs, stage: bool) -> Result<PathBuf> {
let src = match &a.bin {
Some(p) => p.clone(),
None => std::env::current_exe().context("resolve this executable for --bin default")?,
};
match scope {
Scope::User => Ok(src.canonicalize().unwrap_or(src)),
Scope::System => {
let dst = PathBuf::from(HOLGER_SYSTEM_BIN);
if stage {
if !src.exists() {
anyhow::bail!(
"holger-server binary not found at {} — build it, or pass --bin",
src.display()
);
}
install_binary(&src, &dst)
.with_context(|| format!("stage holger-server to {}", dst.display()))?;
}
Ok(dst)
}
}
}