Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/korp-collectors

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}");
    }
}