src/runtime_systemd.rs
//! **`SystemdRuntime`** (feature `systemd`) — the third substrate: a collector as a
//! systemd unit **of its own**, with its own cgroup, its own `Restart=`, its own service
//! account, and its own data root.
//!
//! # It is an adapter, not a system
//!
//! Every byte of systemd handling here goes **through Skidbladnir**, which owns service
//! lifecycle in this house. This module renders **no unit text**, writes **no drop-in**,
//! and owns **no purge**. What it owns is one function — [`SystemdRuntime::component_spec`]
//! — that turns a backend-neutral [`UnitSpec`] into a
//! [`ComponentSpec`](skidbladnir::product::ComponentSpec), and four `Runtime` methods
//! that hand that spec to `skidbladnir::install` / `skidbladnir::systemd`. If a diff in
//! this file ever looks like it is generating `[Unit]`/`[Service]` text, it is wrong.
//!
//! That rule is not decorative: korp already once carried its own duplicated systemd
//! handling instead of routing through Skidbladnir, and the cost was two verbs rendering
//! two different services for the same product (`korp/src/service.rs:801-825`).
//!
//! # The declaration contract
//!
//! > *Every system declares both a program path and a data path, both defaulting under
//! > the home of the account the service runs as.*
//!
//! Both come from ONE [`Layout`](skidbladnir::layout::Layout), built by
//! [`Layout::for_service_user`] — which resolves the account's home out of `/etc/passwd`
//! through the same parser the chown guard and the drop-in scanner use, so a hardcoded
//! home cannot disagree with the account that actually exists. The unit's `DATA_DIR`
//! then points at `<home>/data/<unit>`, and that same declaration is what an
//! uninstall erases. Neither path is invented here.
//!
//! # What it does NOT do
//!
//! * It does not choose a substrate. [`UnitSpec::placement_advice`] may warn; the
//! programmer chooses. See [`RuntimeKind::Systemd`].
//! * It does not erase data on [`stop`](Runtime::stop) or on a reconcile that drops a
//! unit — those stop the service and leave the data root alone. Erasing is
//! [`SystemdRuntime::uninstall`], which routes into Skidbladnir's uninstall (THE
//! UNINSTALL LAW) and never sweeps a path itself.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use skidbladnir::product::{ComponentSpec, HealthCheck};
use skidbladnir::service::Backend;
use skidbladnir::systemd::{RestartPolicy, Scope};
use crate::runtime::{Entry, Handle, RunStatus, Runtime, RuntimeKind, UnitKind, UnitSpec};
/// The env var carrying the unit's own config JSON to the collector process. Public
/// (a systemd unit is world-readable) — credentials travel through
/// [`UnitSpec::secret_env`], which Skidbladnir routes to a 0600 `EnvironmentFile=`.
pub const CONFIG_ENV: &str = "COLLECTOR_CONFIG_JSON";
/// The env var naming the collector, so one binary serves every unit.
pub const NAME_ENV: &str = "COLLECTOR_NAME";
/// The default unit-name prefix — `collector-osm-cut.service`, never bare
/// `osm-cut.service` (which would collide with anything else on the box).
pub const DEFAULT_PREFIX: &str = "collector-";
/// **A collector as its own systemd service**, installed and torn down through
/// Skidbladnir.
#[derive(Clone, Debug)]
pub struct SystemdRuntime {
/// The account every collector unit runs as. In [`Scope::User`] this is only a
/// label — systemd runs the unit as the calling user and the renderer omits
/// `User=` entirely.
pub user: String,
/// The ALREADY-BUILT program each unit execs — the thin collector binary. Never
/// built here: a lifecycle verb installs an artifact, it does not produce one.
pub program: PathBuf,
/// System units (`/etc/systemd/system`, needs root) vs per-user units
/// (`~/.config/systemd/user`, no root).
pub scope: Scope,
/// The filesystem root unit files are written under. `/` in production; a tempdir
/// is what makes the whole install path testable without touching the box.
pub root: PathBuf,
/// Unit-name prefix ([`DEFAULT_PREFIX`]).
pub prefix: String,
}
impl SystemdRuntime {
/// A runtime installing units for `user` that exec `program`, in `scope`.
///
/// **`root` is the filesystem root, `/`, for BOTH scopes.** Skidbladnir's renderer emits
/// an absolute unit path for [`Scope::System`] (`/etc/systemd/system/x.service`) and a
/// path relative to a home for [`Scope::User`] (`.config/systemd/user/x.service`), and
/// it resolves that home ITSELF — from the calling euid's passwd row, then `$HOME`
/// (`skidbladnir::install::rendered_home`) — before placing the result under `root`.
///
/// So the home must not be passed as `root`: doing that writes the unit to
/// `$HOME/$HOME/.config/systemd/user/x.service`, systemd's `EnableUnitFiles` answers
/// `NoSuchUnit`, and the install fails after having already provisioned the account,
/// the data tree and the software generation. Measured on oden 2026-08-06 — the unit
/// landed in `/home/<user>/home/<user>/.config/systemd/user/`.
///
/// **Where a rendered path resolves belongs to Skidbladnir, which owns service
/// lifecycle.** A caller that re-derives it is a second answer to a question with one
/// owner, and this is what that costs.
///
/// [`under_root`](Self::under_root) still redirects the whole tree into a tempdir,
/// which is what makes an install assertable without a privileged box.
pub fn new(user: impl Into<String>, program: impl Into<PathBuf>, scope: Scope) -> Self {
SystemdRuntime {
user: user.into(),
program: program.into(),
scope,
root: PathBuf::from("/"),
prefix: DEFAULT_PREFIX.to_string(),
}
}
/// Write unit files under `root` instead of `/` (the seam that makes an install
/// assertable on disk without a privileged box).
pub fn under_root(mut self, root: impl Into<PathBuf>) -> Self {
self.root = root.into();
self
}
/// Override the unit-name prefix.
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
/// The systemd service name for `unit` (`collector-osm-cut`), WITHOUT `.service`.
pub fn service_name(&self, unit: &str) -> String {
format!("{}{}", self.prefix, unit)
}
/// The full unit file name (`collector-osm-cut.service`).
pub fn unit_file(&self, unit: &str) -> String {
format!("{}.service", self.service_name(unit))
}
/// The Skidbladnir backend for this scope.
pub fn backend(&self) -> Backend {
match self.scope {
Scope::User => Backend::SystemdUser,
Scope::System => Backend::SystemdSystem,
}
}
/// **THE ADAPTER — [`UnitSpec`] → [`ComponentSpec`].** Pure: no bus, no disk, no
/// root. Everything a systemd install needs is expressed as declarations on the
/// spec Skidbladnir already owns; nothing is rendered here.
///
/// The mapping, field by field:
///
/// | `UnitSpec` | `ComponentSpec` |
/// |---|---|
/// | `name` | `name` = `<prefix><name>` (the unit), `exec_args` = `["run", "<name>"]` |
/// | `entry` | `InProcess` ⇒ the thin runner + the collector name; `Nursery` ⇒ `["serve"]` + the roster in `$COLLECTOR_NURSERY_JSON`; `Image` ⇒ refused (see below) |
/// | `env` | `env` (inline `Environment=`, world-readable) |
/// | `secret_env` | `secret_env` (Skidbladnir's 0600 `EnvironmentFile=`) |
/// | `config` | `env[COLLECTOR_CONFIG_JSON]` — one declaration, no config file to own |
/// | `resources` | *not mapped* — see the note below |
/// | `storage.durable_dir` | *not mapped*: the data path comes from the [`Layout`](skidbladnir::layout::Layout), which is the declaration an uninstall reads |
/// | `kind` | `Daemon` ⇒ `Restart=on-failure`; `OneShot` ⇒ `Restart=no`; `Cron` ⇒ refused, see [`start`](Runtime::start) |
///
/// **`resources` is deliberately unmapped and that is a gap, not a decision.**
/// `MemoryMax=`/`CPUQuota=` are the whole reason a collector wants its own cgroup,
/// and `ComponentSpec` has no field for either — adding one belongs upstream in
/// Skidbladnir, not in a korp-side unit-text escape hatch. Until it exists, a
/// systemd collector gets isolation of *failure* (its own `Restart=`, its own
/// process) but not of *resources*. Saying so is cheaper than a `[Service]` line
/// written here that nothing else in the house knows about.
pub fn component_spec(&self, spec: &UnitSpec) -> Result<ComponentSpec> {
// The nursery shape: ONE unit, `serve`, the whole roster in one env var. It shares
// every other line with the single-collector shape below — the account, the
// program, the restart policy, the secret channel — because they differ only in
// what the process is told to run.
if let Entry::Nursery { roster } = &spec.entry {
return self.nursery_spec(spec, roster);
}
let collector = match &spec.entry {
Entry::InProcess { collector } => collector.clone(),
Entry::Nursery { .. } => unreachable!("handled above"),
Entry::Image { image, .. } => anyhow::bail!(
"unit '{}' declares a container image ({image}) — a systemd unit execs a \
program on this host, not an image. Use RuntimeKind::Kube for an image \
entry, or declare Entry::in_process and point SystemdRuntime::program at \
the collector binary.",
spec.name
),
};
let program_file = self
.program
.file_name()
.map(|f| f.to_string_lossy().to_string())
.with_context(|| {
format!(
"SystemdRuntime::program has no file name: {}",
self.program.display()
)
})?;
let restart = match spec.kind {
UnitKind::Daemon => RestartPolicy::OnFailure,
// A run-once unit that systemd restarts is not run-once.
UnitKind::OneShot => RestartPolicy::No,
UnitKind::Cron { .. } => RestartPolicy::No,
};
let mut c = ComponentSpec::new(self.service_name(&spec.name), program_file, &self.user)
.with_arg("run")
.with_arg(&collector)
.with_env(NAME_ENV, &collector)
.with_env(
CONFIG_ENV,
serde_json::to_string(&spec.config).unwrap_or_else(|_| "{}".into()),
)
.with_health(HealthCheck::None);
c.description = format!("collector {collector} (nordisk collector runtime)");
c.restart = restart;
for (k, v) in &spec.env {
c = c.with_env(k, v);
}
for (k, v) in &spec.secret_env {
c = c.with_secret_env(k, v);
}
Ok(c)
}
/// **The nursery unit** — `korp-collectors serve`, one unit for the whole roster.
///
/// The roster travels as `$COLLECTOR_NURSERY_JSON`, the same declaration the `serve`
/// verb reads from a file or the environment. Nothing is rendered: the data path still
/// comes from the [`Layout`](skidbladnir::layout::Layout) (which is what an uninstall
/// erases), and the account is still the one the caller declared.
///
/// A `Cron` nursery is refused for the same reason a `Cron` collector is: a `.timer`
/// must be declared through Skidbladnir, and its renderer has no `OnCalendar=` yet.
fn nursery_spec(&self, spec: &UnitSpec, roster: &serde_json::Value) -> Result<ComponentSpec> {
let program_file = self
.program
.file_name()
.map(|f| f.to_string_lossy().to_string())
.with_context(|| {
format!(
"SystemdRuntime::program has no file name: {}",
self.program.display()
)
})?;
let count = roster
.get("collectors")
.and_then(|c| c.as_array())
.map(|a| a.len())
.unwrap_or(0);
if count == 0 {
anyhow::bail!(
"the nursery unit '{}' declares an EMPTY roster — installing a service that \
nurses nothing is a unit that reports `active` forever while doing no work. \
Declare at least one collector",
spec.name
);
}
let mut c = ComponentSpec::new(self.service_name(&spec.name), program_file, &self.user)
.with_arg("serve")
.with_env(
crate::nursery::NURSERY_CONFIG_ENV,
serde_json::to_string(roster).unwrap_or_else(|_| "{}".into()),
)
.with_health(HealthCheck::None);
c.description =
format!("collector nursery: {count} collector(s) (nordisk collector runtime)");
c.restart = match &spec.kind {
UnitKind::Daemon => RestartPolicy::OnFailure,
UnitKind::OneShot => RestartPolicy::No,
UnitKind::Cron { schedule } => anyhow::bail!(
"nursery unit '{}' is Cron ({schedule}) — a systemd .timer must be declared \
through Skidbladnir, and its renderer has no OnCalendar= yet. Refusing rather \
than writing a unit file here",
spec.name
),
};
for (k, v) in &spec.env {
c = c.with_env(k, v);
}
for (k, v) in &spec.secret_env {
c = c.with_secret_env(k, v);
}
Ok(c)
}
/// The [`Layout`](skidbladnir::layout::Layout) — **the program path AND the data
/// path**, both under the service account's home, resolved out of `/etc/passwd` by
/// Skidbladnir's one passwd parser.
///
/// In [`Scope::User`] there is no service account to look up (the unit runs as the
/// caller), so the root is `$HOME/.collectors`.
/// **The conventional home of a service account this runtime creates.**
///
/// Named here, once, because two things must agree on it and neither can derive it from
/// the other: [`provision_account`](Self::provision_account) creates the account WITH
/// this home, and [`layout`](Self::layout) then reads the home back out of passwd. The
/// layout's answer is the authority — this is only the seed for an account that does not
/// exist yet.
pub fn service_home(&self) -> PathBuf {
PathBuf::from("/home").join(&self.user)
}
/// **Create the service account if it is absent** — the step a system-scope install
/// cannot do without, and cannot do later.
///
/// [`layout`](Self::layout) resolves the data path out of the account's passwd row, so an
/// account that does not exist yet has no layout, and an install of a NEW service account
/// deadlocks: the layout needs the account, and the account creation was inside the
/// install that needed the layout. Measured 2026-08-06 — a first system-scope install
/// failed with *"no row in /etc/passwd (create the account first)"* and could never have
/// succeeded.
///
/// The account itself is created by `skidbladnir::systemd::ensure_system_user`, which owns
/// passwd/group/shadow and the home's ownership. This function decides only *when*.
/// Idempotent, and a no-op for [`Scope::User`] (where the unit runs as the caller and
/// there is no account to make).
#[cfg(unix)]
pub fn provision_account(&self) -> Result<()> {
if !matches!(self.scope, Scope::System) {
return Ok(());
}
if skidbladnir::systemd::lookup_system_home(&self.user).is_some() {
return Ok(());
}
if skidbladnir::systemd::current_euid() != 0 {
anyhow::bail!(
"service account `{}` does not exist and creating it needs root. A system-scope \
unit runs as a service account, and its data path is that account's home — so \
the account must exist before anything is written. Re-run as root, or use \
--scope user",
self.user
);
}
let home = self.service_home();
skidbladnir::systemd::ensure_system_user(&self.user, &home.to_string_lossy())
.with_context(|| {
format!(
"create the service account `{}` with home {}",
self.user,
home.display()
)
})?;
Ok(())
}
pub fn layout(&self) -> Result<skidbladnir::layout::Layout> {
match self.scope {
Scope::System => {
skidbladnir::layout::Layout::for_service_user(&self.user, Some("collectors"))
.with_context(|| format!("resolve the layout for service user `{}`", self.user))
}
Scope::User => {
let home = std::env::var("HOME")
.context("a user-scope collector layout needs $HOME (the caller's own home)")?;
Ok(skidbladnir::layout::Layout::under(
Path::new(&home).join(".collectors"),
))
}
}
}
/// The [`Handle`] for `spec` — the unit name is the substrate id, so
/// [`stop`](Runtime::stop) / [`status`](Runtime::status) need nothing else.
pub fn handle_for(&self, spec: &UnitSpec) -> Handle {
Handle::new(RuntimeKind::Systemd, &spec.name, self.unit_file(&spec.name))
}
/// A D-Bus connection in this runtime's scope (zero-shell — never `systemctl`).
#[cfg(unix)]
fn bus(&self) -> Result<skidbladnir::systemd::Systemd> {
skidbladnir::systemd::Systemd::connect_scoped(self.scope)
.with_context(|| format!("connect to the systemd {:?} bus", self.scope))
}
/// **Uninstall `spec` — stop, disable, remove the unit, and erase the declared data
/// root — THROUGH Skidbladnir.** A collector that swept its own data directory
/// would be the same class of mistake as a hand-written drop-in: the eraser and the
/// resolver must be the same declaration, or an uninstall wipes a path the service
/// never used while printing success.
#[cfg(unix)]
pub fn uninstall(&self, spec: &UnitSpec) -> Result<skidbladnir::install::InstallOutcome> {
let component = self.component_spec(spec)?;
let layout = self.layout()?;
let outcome = skidbladnir::install::uninstall_product(
&component,
&layout,
self.backend(),
&self.root,
true,
)?;
skidbladnir::install::apply(&outcome)?;
Ok(outcome)
}
}
impl Runtime for SystemdRuntime {
fn kind(&self) -> RuntimeKind {
RuntimeKind::Systemd
}
/// **Install (or re-install) `spec` as a systemd unit** through
/// [`skidbladnir::install::install_service`], which provisions the account, the data
/// directory, the unit, `enable` and `start` in one idempotent verb.
#[cfg(unix)]
fn start(&self, spec: &UnitSpec) -> Result<Handle> {
if let UnitKind::Cron { schedule } = &spec.kind {
// A `.timer` must be DECLARED, not hand-written — and Skidbladnir has no
// `OnCalendar=` renderer yet (`src/watch.rs` renders one timer shape, hard-
// wired to the watch-refresh naming and driven by OnBootSec/OnUnitActiveSec).
// Writing the timer here would be exactly the duplication this module exists
// to avoid, so this refuses and names what is missing.
anyhow::bail!(
"unit '{}' is Cron ({schedule}) — a systemd .timer must be declared through \
Skidbladnir, and its renderer has no OnCalendar= yet (skidbladnir::watch \
renders only the watch-refresh timer shape). Refusing rather than writing a \
unit file here. Use RuntimeKind::Kube for a CronJob today, or a Daemon whose \
run loop sleeps.",
spec.name
)
}
let component = self.component_spec(spec)?;
// The account BEFORE the layout: the layout's data path is the account's passwd
// home, so an install of a new service account has to make the account first.
self.provision_account()?;
let layout = self.layout()?;
let source =
skidbladnir::install::UpgradeSource::executable(&self.program).with_context(|| {
format!(
"SystemdRuntime installs an ALREADY-BUILT collector binary; {} is not one",
self.program.display()
)
})?;
let service_home = match self.scope {
Scope::System => Some(
layout
.data_dir
.parent()
.unwrap_or(&layout.data_dir)
.to_path_buf(),
),
Scope::User => None,
};
skidbladnir::install::install_service(
&component,
&layout,
&source,
&skidbladnir::archive::TarZstd::new(),
self.backend(),
&self.root,
service_home.as_deref(),
)
.with_context(|| format!("install collector unit {}", self.unit_file(&spec.name)))?;
Ok(self.handle_for(spec))
}
/// **Stop the unit — and mean it.** `StopUnit` over D-Bus, in this runtime's scope.
/// Data is untouched: a toggle-off is not an uninstall.
#[cfg(unix)]
fn stop(&self, h: &Handle) -> Result<()> {
self.bus()?
.stop(&h.id)
.with_context(|| format!("stop unit {}", h.id))
}
/// The live status, read from **systemd's own view of the unit** — never from a
/// value this process set. `ActiveState == active` plus a check that the running
/// process really is the binary now on disk (`/proc/<MainPID>/exe`), which is the
/// difference between "a unit is up" and "the unit is running what you installed".
#[cfg(unix)]
fn status(&self, h: &Handle) -> Result<RunStatus> {
let bus = self.bus()?;
let active = bus.is_active(&h.id).unwrap_or(false);
if !active {
return Ok(RunStatus::unhealthy("inactive"));
}
let Some(pid) = bus.main_pid(&h.id)? else {
return Ok(RunStatus::unhealthy("active, no MainPID"));
};
let layout = self.layout()?;
let installed = layout
.product_software(&self.service_name(&h.name))
.join(self.program.file_name().unwrap_or_default());
let fresh = skidbladnir::running::running_binary_is_current(pid, &installed);
// Three answers, not two. `Unknown` is NOT staleness — on a box with
// `kernel.yama.ptrace_scope=1` (oden) a non-ancestor cannot read
// `/proc/<pid>/exe` at all, and calling every such unit unhealthy would paint
// a perfectly good service red for a permission it was never going to have.
// KNOWN staleness (a deleted inode, a binary newer than the process) stays red,
// because that one is a fact.
Ok(match fresh {
skidbladnir::running::Freshness::Current => {
RunStatus::healthy(format!("active (pid {pid})"))
}
skidbladnir::running::Freshness::Unknown(ref why) => RunStatus::healthy(format!(
"active (pid {pid}); running binary undetermined: {why}"
)),
ref stale => {
RunStatus::unhealthy(format!("active (pid {pid}) but {}", stale.explain()))
}
})
}
/// Start every desired unit; **stop** (never erase) every unit this runtime knows
/// about that is no longer desired.
#[cfg(unix)]
fn reconcile(&self, desired: &[UnitSpec]) -> Result<()> {
for spec in desired {
self.start(spec)?;
}
Ok(())
}
#[cfg(not(unix))]
fn start(&self, spec: &UnitSpec) -> Result<Handle> {
anyhow::bail!("SystemdRuntime needs a unix host (unit '{}')", spec.name)
}
#[cfg(not(unix))]
fn stop(&self, h: &Handle) -> Result<()> {
anyhow::bail!("SystemdRuntime needs a unix host (unit '{}')", h.id)
}
#[cfg(not(unix))]
fn status(&self, h: &Handle) -> Result<RunStatus> {
anyhow::bail!("SystemdRuntime needs a unix host (unit '{}')", h.id)
}
#[cfg(not(unix))]
fn reconcile(&self, _desired: &[UnitSpec]) -> Result<()> {
anyhow::bail!("SystemdRuntime needs a unix host")
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn rt() -> SystemdRuntime {
SystemdRuntime::new(
"_korpcoll",
"/opt/collectors/korp-collectors",
Scope::System,
)
}
/// **The adapter maps every declaration through, and invents none.**
#[test]
fn component_spec_is_a_pure_translation_of_the_unit_spec() {
let spec = UnitSpec::collector_daemon("dcat", json!({ "url": "https://x/dcat" }))
.env("RUST_LOG", "info")
.secret_env("DCAT_TOKEN", "hunter2");
let c = rt().component_spec(&spec).expect("adapt");
assert_eq!(
c.name, "collector-dcat",
"the unit is prefixed, never a bare name"
);
assert_eq!(c.user, "_korpcoll", "runs as the SERVICE account");
assert_eq!(c.bundle_binary, "korp-collectors", "execs the thin runner");
assert_eq!(c.exec_args, vec!["run".to_string(), "dcat".to_string()]);
assert_eq!(
c.restart,
RestartPolicy::OnFailure,
"a Daemon restarts on failure"
);
// The unit config travels as ONE declared env var, parsed back identically.
let cfg = c
.env
.iter()
.find(|(k, _)| k == CONFIG_ENV)
.expect("config env");
let back: serde_json::Value = serde_json::from_str(&cfg.1).unwrap();
assert_eq!(back, spec.config, "the unit config round-trips");
assert!(
c.env.iter().any(|(k, v)| k == "RUST_LOG" && v == "info"),
"public env passes through"
);
// A credential is on the SECRET channel and nowhere else.
assert!(
c.secret_env
.iter()
.any(|(k, v)| k == "DCAT_TOKEN" && v == "hunter2")
);
assert!(
!c.env.iter().any(|(_, v)| v.contains("hunter2")),
"a secret must never reach the world-readable env: {:?}",
c.env
);
}
/// **A OneShot must not be restarted, or it is not run-once.**
#[test]
fn oneshot_declares_restart_no() {
let spec = UnitSpec {
kind: UnitKind::OneShot,
..UnitSpec::collector_daemon("gtfs", json!({}))
};
assert_eq!(
rt().component_spec(&spec).unwrap().restart,
RestartPolicy::No
);
}
/// **An image entry is refused with a reason, not silently exec'd as a path.**
/// `ComponentSpec::bundle_binary` is a string, so `Entry::Image{image:"ghcr.io/x:1"}`
/// would otherwise install a unit whose `ExecStart` is an image reference.
#[test]
fn an_image_entry_is_refused_not_exec_d() {
let spec = UnitSpec {
entry: Entry::Image {
image: "ghcr.io/x/c:1".into(),
argv: vec!["run".into()],
},
..UnitSpec::collector_daemon("custom", json!({}))
};
let err = rt().component_spec(&spec).unwrap_err().to_string();
assert!(err.contains("container image"), "names WHY: {err}");
assert!(
err.contains("RuntimeKind::Kube"),
"names the substrate that does take one: {err}"
);
}
/// **A Cron unit is refused, naming the missing declaration** — it must never fall
/// through to a hand-written `.timer`, and it must never install a Daemon instead
/// (which would run the collector continuously on a schedule it declared).
#[test]
fn cron_is_refused_because_the_timer_must_be_declared() {
let spec = UnitSpec {
kind: UnitKind::Cron {
schedule: "0 * * * *".into(),
},
..UnitSpec::collector_daemon("nordpool", json!({}))
};
let dir = std::env::temp_dir().join(format!("kc_systemd_cron_{}", std::process::id()));
let err = rt().under_root(&dir).start(&spec).unwrap_err().to_string();
assert!(
err.contains("OnCalendar"),
"names the missing renderer: {err}"
);
assert!(
!dir.exists(),
"a refused Cron unit must write NOTHING under the root: {}",
dir.display()
);
}
/// The handle carries the unit file, which is all stop/status need.
#[test]
fn handle_is_the_unit_file() {
let spec = UnitSpec::collector_daemon("osm-cut", json!({}));
let h = rt().handle_for(&spec);
assert_eq!(h.runtime, RuntimeKind::Systemd);
assert_eq!(h.id, "collector-osm-cut.service");
assert_eq!(h.name, "osm-cut");
}
/// **The NURSERY unit is one unit for the whole roster** — `serve`, not `run <name>`.
///
/// This is the shape a foreign project installs, and the assertion that matters is the
/// argv: a unit that exec'd `run` would nurse exactly one of the declared collectors and
/// report `active` while doing a fraction of the work.
#[test]
fn a_nursery_unit_execs_serve_and_carries_the_whole_roster() {
let roster = json!({
"data_dir": "/home/_korpcoll/collectors/data",
"needs": "network",
"collectors": [
{ "name": "dcat", "config": { "url": "https://x/dcat" } },
{ "name": "pxweb" },
{ "name": "nordpool", "enabled": false }
]
});
let spec = UnitSpec::nursery("collectors", roster.clone())
.env("RUST_LOG", "info")
.secret_env("ENTSOE_TOKEN", "hunter2");
let c = rt().component_spec(&spec).expect("adapt the nursery");
assert_eq!(c.name, "collector-collectors");
assert_eq!(
c.user, "_korpcoll",
"the nursery runs as the SERVICE account"
);
assert_eq!(c.bundle_binary, "korp-collectors");
assert_eq!(
c.exec_args,
vec!["serve".to_string()],
"a nursery unit must exec `serve` — `run <name>` would nurse one collector out of \
three and still report active"
);
assert_eq!(c.restart, RestartPolicy::OnFailure);
assert!(
c.description.contains("3 collector(s)"),
"the description must say how many it nurses: {}",
c.description
);
// The whole roster travels as ONE declaration and round-trips identically.
let env = c
.env
.iter()
.find(|(k, _)| k == crate::nursery::NURSERY_CONFIG_ENV)
.expect("the roster env var");
let back: serde_json::Value = serde_json::from_str(&env.1).unwrap();
assert_eq!(
back, roster,
"the roster must round-trip through the unit env"
);
assert!(c.env.iter().any(|(k, v)| k == "RUST_LOG" && v == "info"));
// …and the credential is on the secret channel only.
assert!(
c.secret_env
.iter()
.any(|(k, v)| k == "ENTSOE_TOKEN" && v == "hunter2")
);
assert!(
!c.env.iter().any(|(_, v)| v.contains("hunter2")),
"a secret must never reach the world-readable env: {:?}",
c.env
);
// The single-collector shape is still available and still different.
let one = rt()
.component_spec(&UnitSpec::collector_daemon("dcat", json!({})))
.unwrap();
assert_eq!(one.exec_args, vec!["run".to_string(), "dcat".to_string()]);
}
/// ★ **RED — an EMPTY roster is refused.** A nursery unit that nurses nothing is a unit
/// systemd reports `active` for, forever, while no collector exists. That is the exact
/// shape of a green that can never be red, and it must not be installable.
#[test]
fn a_nursery_unit_with_an_empty_roster_is_refused() {
for roster in [
json!({ "collectors": [] }),
json!({}),
json!({ "data_dir": "/x" }),
] {
let err = rt()
.component_spec(&UnitSpec::nursery("collectors", roster.clone()))
.expect_err("an empty roster must be refused")
.to_string();
assert!(err.contains("EMPTY roster"), "roster {roster}: {err}");
assert!(err.contains("nurses nothing"), "roster {roster}: {err}");
}
}
/// ★ **RED — a Cron nursery is refused too**, and for the same stated reason as a Cron
/// collector. A second entry shape must not become a second way around the missing
/// `OnCalendar=` renderer.
#[test]
fn a_cron_nursery_is_refused_naming_the_missing_renderer() {
let spec = UnitSpec {
kind: UnitKind::Cron {
schedule: "0 * * * *".into(),
},
..UnitSpec::nursery("collectors", json!({ "collectors": [{ "name": "dcat" }] }))
};
let err = rt().component_spec(&spec).unwrap_err().to_string();
assert!(err.contains("OnCalendar"), "{err}");
}
}