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

nordisk/gunnar-ui

crates/gunnar-ui-server/src/lifecycle.rs

//! **gunnar-ui-server as a skidbladnir-managed service.**
//!
//! Every service in the suite goes through skidbladnir — unit generation,
//! install, uninstall. Until 2026-09-13 this console was the one that did not:
//! `deploy/gunnar-ui.service` was a hand-written file that `deploy/deploy.sh`
//! `tar`red onto the host and `cp`ed into `/etc/systemd/system` when `cmp` said
//! it differed. The unit is declared HERE now, in the shape
//! `monetize-ui-server`'s `lifecycle.rs` has: one [`ServiceSpec`], one
//! [`Lifecycle`]; [`unit_text`] renders it, [`install`] writes it.
//!
//! ## What is deliberately NOT changed
//!
//! **`User=root`.** The hand-written unit named no user, which in a system unit
//! means root, and that is what the console on the front has been running as:
//! `/etc/gunnar-ui.env` is mode 0600 root, `console_key` and `state.redb` are
//! root's. skidbladnir's renderer always emits `User=`/`Group=` in system scope,
//! so the account is now WRITTEN DOWN rather than implied — but it is the same
//! account. Moving the console to a service account of its own is a change to
//! the running deployment (three files to re-own and a re-enrolment if the key
//! moves), and it does not belong in the commit that changes how the unit is
//! generated.
//!
//! **No inline `Environment=` lines.** The hand-written unit had none, on
//! purpose, and its comment is the reason: systemd applies `EnvironmentFile=`
//! after `Environment=` whatever order they appear in, so a value in the unit is
//! ignored and the file always wins. Everything configurable is in
//! [`crate::envfile`] and reaches the process through
//! `EnvironmentFile=/etc/gunnar-ui.env`.
//!
//! **No `StartLimit`.** `monetize-ui-server` declares `give_up_after(5)`; this
//! one does not, because the hand-written unit did not and the difference is not
//! cosmetic — a start limit parks the unit in `failed` instead of retrying, and
//! that is a change to how the console behaves on a bad night. It can be added
//! deliberately later; it must not arrive as a side effect of this port.
//!
//! ## The service home the install must not create
//!
//! `WorkingDirectory=` is `/opt/gunnar-ui/current`, and `current` is a SYMLINK
//! that the release swap renames over. skidbladnir's privileged install creates
//! and re-owns the spec's *service home*, so a spec that declared this path as
//! its home would, on a host with no release yet, `create_dir_all` a REAL
//! directory called `current` — and the swap's `rename(2)` can never replace a
//! directory with a symlink. This spec therefore declares NO service home:
//! `/opt/gunnar-ui` is made by the deploy, not by the install.

use std::path::Path;

use skidbladnir::service::{AgentMode, HealthProbe, Lifecycle, ServiceSpec};
use skidbladnir::systemd::{RestartPolicy, Scope, SystemdServiceSpec};

/// The unit's name — `gunnar-ui.service`, the name the hand-written file had and
/// the name `systemctl restart gunnar-ui` on the box still means.
pub const UNIT: &str = "gunnar-ui";
/// `[Unit] Description=`, verbatim from the file this replaces.
pub const DESCRIPTION: &str = "gunnar console (BFF + SPA)";
/// The release layout's root. Everything below is either a release or state that
/// deliberately outlives one.
pub const ROOT: &str = "/opt/gunnar-ui";
/// The symlink both paths go through, so one rename moves the binary and the
/// bundle together. A deploy that changed them separately could serve a new
/// console against an old server for as long as the restart took.
pub const CURRENT: &str = "/opt/gunnar-ui/current";
/// `ExecStart=`.
pub const EXEC: &str = "/opt/gunnar-ui/current/gunnar-ui-server";

/// What the unit is generated from. Everything else this console needs is in
/// `/etc/gunnar-ui.env`; this is only what systemd itself has to be told.
#[derive(Debug, Clone)]
pub struct Settings {
    /// Where the console listens — read back out of the env file, because the
    /// health probe has to dial the address the service actually binds and not
    /// a second copy of the number.
    pub bind: String,
}

impl Default for Settings {
    fn default() -> Self {
        Settings {
            bind: "127.0.0.1:3210".into(),
        }
    }
}

impl Settings {
    /// Take the bind address from a parsed env file, falling back to the
    /// compiled default the server itself falls back to.
    pub fn from_env_file(entries: &[(String, String)]) -> Settings {
        let bind = entries
            .iter()
            .find(|(k, _)| k == "GUNNAR_UI_BIND")
            .map(|(_, v)| v.trim().to_string())
            .filter(|v| !v.is_empty())
            .unwrap_or_else(|| Settings::default().bind);
        Settings { bind }
    }
}

/// The backend-neutral declaration.
pub fn service_spec(exec: &Path, s: &Settings, scope: Scope) -> ServiceSpec {
    let port = skidbladnir::service::split_bind_addr(&s.bind)
        .map(|(_, p)| p)
        .unwrap_or(3210);
    ServiceSpec::new(UNIT, DESCRIPTION, exec.display().to_string())
        .scope(scope)
        // See the module docs: written down, not changed.
        .user("root")
        // `RestartSec=3`, not skidbladnir's default of 2 — the hand-written unit
        // said 3 and this port changes no behaviour it is not asked to.
        .restart(RestartPolicy::OnFailure)
        .restart_sec(3)
        // Not rendered into the unit; it is what `--status` dials to answer
        // "healthy", and it must be the address the env file binds.
        .health(HealthProbe::Tcp(port))
        .agent(AgentMode::Fat)
        // THE PROGRAM PATH. `current` and the binary's parent both canonicalise
        // to the same release directory, so this asserts that the unit and the
        // file were placed by the same step — the assertion skidbladnir added
        // after a unit was found executing out of a cargo target tree.
        .with_program_dirs([CURRENT])
        // THE DATA ROOT, with the sub-directories this console owns named
        // explicitly, because the root is SHARED: `/opt/gunnar-ui/data` is
        // gunnar's own repository storage and `deploy.sh` says so in its layout
        // comment ("gunnar's own, untouched by this script"). An uninstall that
        // swept every child of the root would delete the forge. `console_key`,
        // `guest_key` and `state.redb` are likewise left alone: an uninstall that
        // forgets the passkeys is an uninstall nobody can undo.
        .state_dir("releases")
        .state_dir("incoming")
}

/// The neutral spec with a data root attached, or without one and saying so.
///
/// `DataRoot::from_env` refuses a root that is or contains a real user's home, so
/// a refusal here means the value is wrong, not that the uninstall is optional —
/// hence the line on stderr rather than a silent `None`.
pub fn lifecycle(exec: &Path, s: &Settings, scope: Scope) -> Lifecycle {
    let mut spec = service_spec(exec, s, scope);
    match skidbladnir::uninstall::DataRoot::from_env("GUNNAR_UI_DATA", Path::new(ROOT)) {
        Ok(dr) => spec = spec.data_root(&dr),
        Err(e) => eprintln!("{UNIT}: {e} — uninstall would erase nothing; not declaring a root"),
    }
    Lifecycle::new(spec)
}

/// The systemd projection: the neutral spec, plus the two things that are
/// systemd's business and nothing else's.
///
/// `env` is the CONTENT of `/etc/gunnar-ui.env` as it was read off the host.
/// skidbladnir's installer rewrites the `EnvironmentFile=` it is told about from
/// the spec, so the values have to ride along or the install would blank the
/// file — see [`install`], which is where that matters and where it is checked.
/// They ride as SECRETS, never as `env`, so not one of them is rendered into the
/// world-readable 0644 unit.
pub fn systemd_spec(
    exec: &Path,
    s: &Settings,
    scope: Scope,
    env: &[(String, String)],
) -> SystemdServiceSpec {
    let mut sys = lifecycle(exec, s, scope)
        .systemd_spec()
        .with_env_file(crate::envfile::PATH);
    // RENDER-ONLY, and deliberately not the spec's working dir: declaring it
    // there would make it the service home the privileged install creates and
    // re-owns, and `current` is a symlink the swap renames over. See the module
    // docs.
    sys.working_dir = CURRENT.to_string();
    sys = sys.with_service_home(None::<String>);
    for (k, v) in env {
        sys = sys.with_secret_env(k, v);
    }
    sys
}

/// `--unit`: what would be written, without writing it.
pub fn unit_text(exec: &Path, s: &Settings, scope: Scope, env: &[(String, String)]) -> String {
    skidbladnir::systemd::systemd_unit_scoped(&systemd_spec(exec, s, scope, env), scope)
}

/// What an install refuses to do, and why — checked before anything is written.
///
/// This is `deploy.sh`'s "Checking the host's configuration" step, typed. The
/// script ran it over SSH before it moved anything, for a reason it wrote down:
/// getting `GUNNAR_UI_DIR` wrong takes the console down without taking the
/// service down with it — the server starts, `/health` answers, and every page
/// is empty.
#[derive(Debug, PartialEq, Eq)]
pub enum Refusal {
    /// `/etc/gunnar-ui.env` is not there. Nothing in this repository creates it,
    /// on purpose: it holds the credential that is this console's admin.
    NoEnvFile(String),
    /// Declared `Need::Required` keys the file does not set.
    MissingKeys(Vec<&'static str>),
    /// Keys still carrying the template's placeholder.
    Unfilled(Vec<&'static str>),
    /// `GUNNAR_UI_DIR` is not `/opt/gunnar-ui/current/dist`.
    WrongUiDir { found: String },
    /// The install would write back fewer keys than it read. It cannot: the
    /// installer rewrites the env file from the spec, so anything it did not
    /// carry would be silently deleted from a file that holds credentials.
    WouldDropKeys(Vec<String>),
    /// **The host is configured to sell and this binary cannot.** The env file
    /// sets one or more of [`crate::envfile::SELLING`] and the artifact asking
    /// the question was built without `metered`.
    ///
    /// This is the 2026-09-12 failure, typed. It is the only refusal here that
    /// is about the BINARY rather than the file, and it is the reason
    /// `--check-env` is run against the STAGED binary over SSH: the artifact
    /// that is about to become `current` is the only thing that can answer
    /// truthfully, because the answer is compiled into it.
    ConfiguredToSellButCannot(Vec<&'static str>),
}

impl std::fmt::Display for Refusal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Refusal::NoEnvFile(p) => write!(
                f,
                "{p} is missing. Nothing installs it for you, because it holds \
                 GUNNAR_UI_ADMIN_TOKEN and an install that can silently rewrite that is an \
                 install that can silently lock everyone out. Run `gunnar-ui-server \
                 --env-template > {p}`, fill in every REPLACE-ME, `chmod 600 {p}`, and try \
                 again."
            ),
            Refusal::MissingKeys(keys) => write!(
                f,
                "{} sets none of these, and the console needs every one of them: {}",
                crate::envfile::PATH,
                keys.join(", ")
            ),
            Refusal::Unfilled(keys) => write!(
                f,
                "{} still carries the template's placeholder for {}. A placeholder is not a \
                 configured secret, it is a published one.",
                crate::envfile::PATH,
                keys.join(", ")
            ),
            Refusal::WrongUiDir { found } => write!(
                f,
                "GUNNAR_UI_DIR in {} is {found:?}, and this layout needs {:?}. Nothing has \
                 been changed. With it wrong the server starts, answers /health, and serves \
                 an empty console — which is exactly how the first deploy of this layout \
                 failed.",
                crate::envfile::PATH,
                format_args!("{CURRENT}/dist"),
            ),
            Refusal::WouldDropKeys(keys) => write!(
                f,
                "installing would rewrite {} without these keys it already has: {}. Refusing \
                 — nothing has been changed.",
                crate::envfile::PATH,
                keys.join(", ")
            ),
            Refusal::ConfiguredToSellButCannot(keys) => write!(
                f,
                "{} sets {} — keys only a `metered` build acts on — but THIS binary is a \
                 `community` build and has none of that half in it: no /welcome, no /signup, no \
                 /auth/signup/status, no monetize client. `metered` is a NON-DEFAULT feature, \
                 so a bare `cargo build --release -p gunnar-ui-server` produces exactly this \
                 binary, and nothing about it looks wrong: it starts, answers /health and \
                 serves every page. What it answers a stranger returning from an external \
                 signup page is 401 \"log in to use the console\", which is how two live \
                 signups came to be stranded on 2026-09-11 and reversed. Rebuild with \
                 `--features metered`, or clear {} from {} to run a community console on \
                 purpose. Nothing has been changed.",
                crate::envfile::PATH,
                keys.join(", "),
                keys.join(", "),
                crate::envfile::PATH,
            ),
        }
    }
}

/// Every reason this env file is not installable, in the order an operator fixes
/// them. Pure: no filesystem, no root, so the whole refusal set is unit-testable.
///
/// `metered` is what the ARTIFACT is, not what the file says: callers pass
/// [`crate::METERED`], which is compiled in. It is a parameter rather than a
/// `cfg!` inside because a refusal nobody can drive red is a refusal nobody can
/// trust — with it here, one test binary asks the same function both ways.
pub fn refusals(
    env_file_exists: bool,
    entries: &[(String, String)],
    metered: bool,
) -> Vec<Refusal> {
    let mut out = Vec::new();
    if !env_file_exists {
        out.push(Refusal::NoEnvFile(crate::envfile::PATH.to_string()));
        return out;
    }
    let missing = crate::envfile::missing_required(entries);
    if !missing.is_empty() {
        out.push(Refusal::MissingKeys(missing));
    }
    let unfilled = crate::envfile::unfilled(entries);
    if !unfilled.is_empty() {
        out.push(Refusal::Unfilled(unfilled));
    }
    let want = format!("{CURRENT}/dist");
    match entries.iter().find(|(k, _)| k == "GUNNAR_UI_DIR") {
        Some((_, v)) if v.trim() == want => {}
        Some((_, v)) => out.push(Refusal::WrongUiDir {
            found: v.trim().to_string(),
        }),
        // Already reported as a missing required key; not reported twice.
        None => {}
    }
    // LAST, because it is the only one that is about the binary rather than the
    // file, and because an operator who has to fix it fixes it somewhere else
    // entirely: with a `cargo build --features metered`, not with an editor on
    // the box.
    if !metered {
        let selling = crate::envfile::configured_to_sell(entries);
        if !selling.is_empty() {
            out.push(Refusal::ConfiguredToSellButCannot(selling));
        }
    }
    out
}

/// The keys an install would write back, against the keys it read. Anything in
/// the second and not the first is a credential this install would delete.
pub fn dropped_keys(read: &[(String, String)], written: &str) -> Vec<String> {
    let back: Vec<String> = crate::envfile::parse(written)
        .into_iter()
        .map(|(k, _)| k)
        .collect();
    read.iter()
        .map(|(k, _)| k.clone())
        .filter(|k| !back.contains(k))
        .collect()
}

/// **INSTALL.** Refuse, then hand the whole thing to skidbladnir.
///
/// `entries` is `/etc/gunnar-ui.env` as read off this host. It is written back
/// by skidbladnir's installer — that is not optional, the installer rewrites the
/// file it is told to reference — so the last gate before anything is written is
/// that the round trip loses nothing.
#[cfg(unix)]
pub fn install(
    exec: &Path,
    s: &Settings,
    scope: Scope,
    entries: &[(String, String)],
) -> anyhow::Result<()> {
    let refusals = refusals(true, entries, crate::METERED);
    if let Some(first) = refusals.first() {
        anyhow::bail!("{first}");
    }
    let sys = systemd_spec(exec, s, scope, entries);
    let dropped = dropped_keys(entries, &skidbladnir::systemd::env_file(&sys));
    if !dropped.is_empty() {
        anyhow::bail!("{}", Refusal::WouldDropKeys(dropped));
    }
    sys.validate().map_err(|e| anyhow::anyhow!(e))?;
    skidbladnir::systemd::install_systemd_scoped(&sys, scope)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    fn good_env() -> Vec<(String, String)> {
        let mut v = crate::envfile::parse(&crate::envfile::template());
        for (k, val) in v.iter_mut() {
            if k == "GUNNAR_UI_ADMIN_TOKEN" {
                *val = "6c1f0a9d5e3b47a28f0c9d1e5b7a3f4c".into();
            }
            if k == "GUNNAR_UI_MONETIZE_TOKEN_FILE" {
                *val = "/opt/gunnar-ui/monetize-token".into();
            }
            // `GUNNAR_UI_SMTP_TOKEN_FILE` joined the template after this fixture
            // was written, and nothing here filled it — so `unfilled` refused it
            // in EVERY test built on `good_env`, and three of them asserted on
            // the exact refusal list. They were measuring the fixture's own gap
            // rather than the thing each one is named for. Same treatment as the
            // monetize path beside it, for the same reason.
            if k == "GUNNAR_UI_SMTP_TOKEN_FILE" {
                *val = "/opt/gunnar-ui/smtp-token".into();
            }
        }
        // The path placeholder IS the value an operator keeps; `unfilled` only
        // refuses it while it is byte-identical to the template's.
        v.retain(|(k, _)| k != "GUNNAR_UI_MONETIZE_TOKEN_FILE" && k != "GUNNAR_UI_SMTP_TOKEN_FILE");
        v
    }

    #[test]
    fn the_bind_the_probe_dials_comes_from_the_env_file() {
        let s = Settings::from_env_file(&[("GUNNAR_UI_BIND".into(), "127.0.0.1:9999".into())]);
        let spec = service_spec(Path::new(EXEC), &s, Scope::System);
        assert_eq!(spec.health, HealthProbe::Tcp(9999));
        // …and an env file that says nothing falls back to what the server itself
        // falls back to, not to a third number.
        assert_eq!(Settings::from_env_file(&[]).bind, "127.0.0.1:3210");
    }

    #[test]
    fn not_one_configured_value_reaches_the_world_readable_unit() {
        let env = vec![
            ("GUNNAR_UI_ADMIN_TOKEN".into(), "s3cret-token".into()),
            ("GUNNAR_UI_CONTROL".into(), "10.13.8.101:50051".into()),
        ];
        let unit = unit_text(Path::new(EXEC), &Settings::default(), Scope::System, &env);
        assert!(!unit.contains("s3cret-token"), "{unit}");
        assert!(!unit.contains("Environment="), "{unit}");
        assert!(
            unit.contains("EnvironmentFile=/etc/gunnar-ui.env"),
            "{unit}"
        );
        // …and they are all in the 0600 file instead.
        let body = skidbladnir::systemd::env_file(&systemd_spec(
            Path::new(EXEC),
            &Settings::default(),
            Scope::System,
            &env,
        ));
        assert!(
            body.contains("GUNNAR_UI_ADMIN_TOKEN=s3cret-token"),
            "{body}"
        );
    }

    #[test]
    fn the_install_never_creates_a_directory_where_the_swap_needs_a_symlink() {
        // `current` is renamed over by the release swap. A declared service home
        // is `create_dir_all`ed by the privileged install, and a real directory
        // there can never be replaced by rename(2).
        let sys = systemd_spec(Path::new(EXEC), &Settings::default(), Scope::System, &[]);
        assert_eq!(sys.working_dir, CURRENT);
        assert_eq!(sys.service_home, None);
    }

    #[test]
    fn an_uninstall_does_not_delete_gunnars_repositories() {
        let lc = lifecycle(Path::new(EXEC), &Settings::default(), Scope::System);
        let spec = lc.spec();
        assert_eq!(spec.data_root.as_deref(), Some(ROOT));
        // Named sub-dirs, so the sweep is those and not every child of the root.
        assert_eq!(spec.state_dirs, vec!["releases", "incoming"]);
        assert!(!spec.state_dirs.iter().any(|d| d == "data"));
    }

    #[test]
    fn a_host_with_no_env_file_is_refused_and_told_how_to_make_one() {
        let r = refusals(false, &[], METERED_BUILD);
        assert_eq!(r.len(), 1);
        assert!(matches!(r[0], Refusal::NoEnvFile(_)));
        assert!(r[0].to_string().contains("--env-template"));
    }

    #[test]
    fn the_ui_dir_check_deploy_sh_made_over_ssh_is_still_made() {
        let mut env = good_env();
        for (k, v) in env.iter_mut() {
            if k == "GUNNAR_UI_DIR" {
                *v = "/opt/gunnar-ui/releases/20260913T090000Z-94eaa98/dist".into();
            }
        }
        let r = refusals(true, &env, METERED_BUILD);
        assert_eq!(
            r,
            vec![Refusal::WrongUiDir {
                found: "/opt/gunnar-ui/releases/20260913T090000Z-94eaa98/dist".into()
            }]
        );
        // …and a file that names `current` passes.
        assert!(refusals(true, &good_env(), METERED_BUILD).is_empty());
    }

    /// Both halves are DRIVEN here, whichever half this test binary happens to
    /// be: `metered` is an argument, not a `cfg!`. These two constants only name
    /// the two values so the asserts read as what they are about.
    const METERED_BUILD: bool = true;
    const COMMUNITY_BUILD: bool = false;

    /// A host with a buy link and a binary that cannot receive the payer back.
    fn env_that_sells() -> Vec<(String, String)> {
        let mut env = good_env();
        env.push((
            "GUNNAR_UI_BUY_URL".into(),
            "https://buy.stripe.com/6oU5kEfAV1cL4Ny9V9bMQ00".into(),
        ));
        // A path of the operator's own, not the template's: a value still
        // byte-identical to the placeholder is refused by `unfilled` instead,
        // and this test is about the other refusal.
        env.push((
            "GUNNAR_UI_MONETIZE_TOKEN_FILE".into(),
            "/opt/gunnar-ui/secrets/monetize-token".into(),
        ));
        env
    }

    /// ★ **The 2026-09-12 failure, refused before anything moves.**
    ///
    /// The console on the front carried `GUNNAR_UI_MONETIZE` and
    /// `GUNNAR_UI_BUY_URL` and was a binary built without `metered`, so it
    /// advertised a purchase, sent the buyer to the vendor, and met them on the
    /// way back with 401. Two live card payments were taken and refunded. This
    /// is the deploy's `--check-env` step seeing it — run against the STAGED
    /// binary, over SSH, before the symlink swap.
    #[test]
    fn a_console_that_cannot_sell_is_refused_on_a_host_that_expects_to() {
        let r = refusals(true, &env_that_sells(), COMMUNITY_BUILD);
        assert_eq!(
            r,
            vec![Refusal::ConfiguredToSellButCannot(vec![
                "GUNNAR_UI_MONETIZE_TOKEN_FILE",
                "GUNNAR_UI_BUY_URL",
            ])],
            "a community binary on a selling host must be refused BY NAME"
        );
        // The message has to be actionable by somebody reading one line of a
        // deploy log, so it names the rebuild and the keys.
        let said = r[0].to_string();
        assert!(said.contains("--features metered"), "{said}");
        assert!(said.contains("GUNNAR_UI_BUY_URL"), "{said}");
        assert!(said.contains("/auth/signup/status"), "{said}");
    }

    /// The GREEN half, and the two ways it can be green.
    #[test]
    fn the_paid_binary_passes_and_so_does_a_community_one_that_sells_nothing() {
        // The same file, with the paid half actually in the artifact.
        assert!(refusals(true, &env_that_sells(), METERED_BUILD).is_empty());
        // …and a community console on a host that takes no money is the
        // ordinary self-hosted deployment, which must keep installing.
        assert!(refusals(true, &good_env(), COMMUNITY_BUILD).is_empty());
    }

    /// A stock template is not a shop. `GUNNAR_UI_MONETIZE_TOKEN_FILE` is a
    /// `Fill::Placeholder`, and a placeholder is a value nobody chose — counting
    /// it would refuse every community install of an unedited template for a
    /// line that switches nothing on.
    #[test]
    fn an_unfilled_template_does_not_read_as_a_host_that_sells() {
        let template = crate::envfile::parse(&crate::envfile::template());
        assert!(crate::envfile::configured_to_sell(&template).is_empty());
        // And `GUNNAR_UI_MONETIZE` alone never counts: it is only dialled once a
        // bearer is found, so it turns nothing on by itself.
        let addr_only = vec![("GUNNAR_UI_MONETIZE".into(), "https://monetize.ignalina.dk".into())];
        assert!(crate::envfile::configured_to_sell(&addr_only).is_empty());
    }

    #[test]
    fn an_install_that_would_forget_a_key_the_host_already_has_is_refused() {
        // The installer REWRITES /etc/gunnar-ui.env from the spec. If a key the
        // host had did not ride along, the install would delete it from a file
        // that holds credentials — silently, and as root.
        let read = vec![
            ("GUNNAR_UI_BIND".into(), "127.0.0.1:3210".into()),
            ("SOMETHING_AN_OPERATOR_ADDED".into(), "1".into()),
        ];
        let sys = systemd_spec(Path::new(EXEC), &Settings::default(), Scope::System, &read);
        assert!(dropped_keys(&read, &skidbladnir::systemd::env_file(&sys)).is_empty());
        // …and the check can see a drop when there is one.
        let half = systemd_spec(
            Path::new(EXEC),
            &Settings::default(),
            Scope::System,
            &read[..1],
        );
        assert_eq!(
            dropped_keys(&read, &skidbladnir::systemd::env_file(&half)),
            vec!["SOMETHING_AN_OPERATOR_ADDED".to_string()]
        );
    }

    #[test]
    fn the_exec_the_unit_names_is_the_one_the_swap_moves() {
        let unit = unit_text(
            &PathBuf::from(EXEC),
            &Settings::default(),
            Scope::System,
            &[],
        );
        assert!(
            unit.contains("ExecStart=/opt/gunnar-ui/current/gunnar-ui-server"),
            "{unit}"
        );
    }
}