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

nordisk/Skidbladnir

tests/public_api.rs

//! PUBLIC-API seam test — exercises Skíðblaðnir's service/systemd surface through
//! ONLY its public exports (an external consumer's view: holger/nornir/dwarfs).
//!
//! The in-crate `#[cfg(test)]` modules can reach `pub(crate)` internals; this file
//! deliberately cannot, so it pins the exact API those consumers link against:
//! `service::{ServiceSpec, Backend, ServiceRenderer, renderer_for, Socket, Storage,
//! HealthProbe, AgentMode, Lifecycle}`, the shared `systemd::{Scope, RestartPolicy,
//! SystemdServiceSpec, systemd_unit, systemd_unit_scoped, env_file, nornir_spec,
//! holger_spec}`, `k8s::KubernetesRenderer`, and `config::LakeSpec`. If any of these
//! stop being public (or change shape), this test fails to COMPILE — the honest
//! early warning for a downstream break.

use skidbladnir::service::{
    AgentMode, Backend, HealthProbe, Lifecycle, ServiceSpec, Socket, Storage, renderer_for,
};
use skidbladnir::systemd::{
    ChownRefusal, ChownRefused, RestartPolicy, Scope, SystemdServiceSpec, env_file, holger_spec,
    nornir_spec, systemd_unit, systemd_unit_scoped,
};

/// One neutral `ServiceSpec`, built only with public chainers, projects onto every
/// implemented backend through the public `renderer_for`, and each output carries
/// the injected values in that backend's native shape.
#[test]
fn one_public_spec_renders_across_every_implemented_backend() {
    let spec = ServiceSpec::new(
        "holger",
        "Holger artifact repository",
        "/opt/holger/bin/holger",
    )
    .arg("serve")
    .user("_holger")
    .group("_holger")
    .working_dir("/var/lib/holger")
    .env("HOLGER_DATA", "/var/lib/holger")
    .after("nornir-server")
    .restart(RestartPolicy::OnFailure)
    .health(HealthProbe::Tcp(7070))
    .agent(AgentMode::Fat)
    .image("registry.local/holger:v1")
    .namespace("prod")
    .replicas(3)
    .socket(Socket::tcp(7070));

    // systemd-system
    let sd = renderer_for(Backend::SystemdSystem).unwrap().render(&spec).unwrap();
    assert_eq!(sd.backend, Backend::SystemdSystem);
    assert!(
        sd.file_ending("holger.service")
            .unwrap()
            .contains("User=_holger")
    );

    // OpenRC (genuinely different init)
    let rc = renderer_for(Backend::OpenRc).unwrap().render(&spec).unwrap();
    assert!(
        rc.file_ending("/etc/init.d/holger")
            .unwrap()
            .starts_with("#!/sbin/openrc-run")
    );

    // Kubernetes (data-only manifests)
    let k = renderer_for(Backend::Kubernetes).unwrap().render(&spec).unwrap();
    let dep = k.file_ending("holger-deployment.yaml").unwrap();
    assert!(dep.contains("image: \"registry.local/holger:v1\""));
    assert!(dep.contains("replicas: 3"));
    assert!(dep.contains("namespace: \"prod\""));

    // OCI (raw `podman run` argv) and Quadlet (a systemd `.container` unit) both
    // render the same public spec — the container backends carry the image verbatim.
    let oci = renderer_for(Backend::Oci).unwrap().render(&spec).unwrap();
    assert!(
        oci.start
            .join(" ")
            .contains("--entrypoint /opt/holger/bin/holger registry.local/holger:v1 serve")
    );
    let quad = renderer_for(Backend::Quadlet).unwrap().render(&spec).unwrap();
    let cunit = quad.file_ending("holger.container").unwrap();
    assert!(cunit.contains("Image=registry.local/holger:v1"));
    assert!(cunit.contains("Exec=/opt/holger/bin/holger serve"));
    assert_eq!(quad.enable, vec!["systemctl", "daemon-reload"]);

    // Windows service (SCM): a WiX `.wxs` fragment (via the MSI seam) + `sc.exe` verbs.
    let win = renderer_for(Backend::WindowsService).unwrap().render(&spec).unwrap();
    let wxs = win.file_ending(".wxs").unwrap();
    assert!(wxs.contains("<ServiceInstall"));
    assert!(wxs.contains("Account=\"_holger\""));
    assert_eq!(win.start, vec!["sc.exe", "start", "holger"]);

    // Coverage is complete on the public path: EVERY declared backend renders.
    assert!(Backend::all().iter().all(|b| renderer_for(*b).is_some()));

    // The one-call systemd convenience is reachable and matches the renderer.
    assert_eq!(
        spec.render_systemd_unit(),
        sd.file_ending("holger.service").unwrap()
    );
}

/// The public `Lifecycle` exposes unit name + pure unit text and a real health
/// probe, all without root/D-Bus.
#[test]
fn public_lifecycle_unit_and_health() {
    let spec = ServiceSpec::new("svc", "d", "/bin/svc").health(HealthProbe::Tcp(1));
    let lc = Lifecycle::new(spec);
    assert_eq!(lc.unit(), "svc.service");
    assert!(lc.unit_text().contains("ExecStart=/bin/svc"));
    assert!(
        !lc.health().unwrap(),
        "nothing listens on port 1 ⇒ unhealthy"
    );
}

/// The shared systemd generators + the two example presets are public and stable:
/// `nornir_spec`/`holger_spec` build specs whose rendered units carry their
/// signature fields, and `Scope::User` drops `User=`/`Group=`.
#[test]
fn public_systemd_presets_and_scope() {
    let n = nornir_spec("/usr/local/bin/nornir-server", "0.0.0.0:7878");
    assert_eq!(n.unit_file_name(), "nornir.service");
    assert!(env_file(&n).contains("NORNIR_SERVER_ADDR=0.0.0.0:7878"));
    assert!(
        systemd_unit(&n).contains("Environment=PATH=/opt/nornir/bin:/usr/local/bin:/usr/bin:/bin")
    );

    let h = holger_spec("/opt/holger/bin/holger", "/var/lib/holger");
    let sys = systemd_unit_scoped(&h, Scope::System);
    assert!(sys.contains("User=_holger"));
    let usr = systemd_unit_scoped(&h, Scope::User);
    assert!(!usr.contains("User="), "--user unit drops User=");
    assert!(usr.contains("WantedBy=default.target"));

    // The raw builder is public too.
    let s =
        SystemdServiceSpec::new("x", "d", "/bin/x", "x", "/tmp").with_restart(RestartPolicy::No);
    assert!(systemd_unit(&s).contains("Restart=no"));
}

/// The public **service-user** API — the `useradd --system` replacement nornir
/// consumes instead of shelling `useradd` (dup-audit P1). Exercised through the
/// consumer's view only: `create_system_user_in` against a temp fixture root
/// (no live `/etc`, no root) proves the allocation + idempotence contract holds
/// on the public path.
#[cfg(unix)]
#[test]
fn public_service_user_creation_is_reachable_and_idempotent() {
    use skidbladnir::systemd::create_system_user_in;
    let dir = tempfile::tempdir().unwrap();
    let (passwd, group, shadow) = (
        dir.path().join("passwd"),
        dir.path().join("group"),
        dir.path().join("shadow"),
    );
    std::fs::write(&passwd, "root:x:0:0::/root:/bin/sh\n").unwrap();
    std::fs::write(&group, "root:x:0:\n").unwrap();
    std::fs::write(&shadow, "root:!::::::\n").unwrap();

    let (uid, gid) =
        create_system_user_in(&passwd, &group, &shadow, "nornir", "/opt/nornir").unwrap();
    assert_eq!(uid, gid, "system user gets a matching group");
    assert!((100..1000).contains(&uid), "allocated a system id");
    assert!(
        std::fs::read_to_string(&passwd)
            .unwrap()
            .contains("nornir:x:")
    );
    assert!(
        std::fs::read_to_string(&passwd)
            .unwrap()
            .contains(":/opt/nornir:/usr/sbin/nologin")
    );

    // Idempotent: same ids, no duplicate row (the `!exists` guard nornir relied on).
    let again = create_system_user_in(&passwd, &group, &shadow, "nornir", "/opt/nornir").unwrap();
    assert_eq!(again, (uid, gid));
    assert_eq!(
        std::fs::read_to_string(&passwd)
            .unwrap()
            .matches("nornir:x:")
            .count(),
        1,
        "no duplicate user row",
    );

    // `ensure_system_user` + `chown_recursive` are public symbols on the consumer path.
    let _ensure: fn(&str, &str) -> anyhow::Result<(u32, u32)> =
        skidbladnir::systemd::ensure_system_user;
    let _chown: fn(&std::path::Path, u32, u32) -> anyhow::Result<()> =
        skidbladnir::systemd::chown_recursive;

    // INCIDENT 2026-07-22: the chown blast-radius guard is PUBLIC so every consumer
    // (dwarves, nornir, holger, modgunn) CONSUMES the one implementation instead of
    // hand-rolling a denylist twin (L5) — and it refuses `/` and `/home` through the
    // public entry point, not just internally.
    let _guard: fn(&std::path::Path, u32, Option<&std::path::Path>) -> Result<(), ChownRefused> =
        skidbladnir::systemd::guard_chown_target;
    let _under: fn(&std::path::Path, &std::path::Path, u32, u32) -> anyhow::Result<()> =
        skidbladnir::systemd::chown_recursive_under;
    for deny in ["/", "/home", "/usr", "/etc", "/var"] {
        let refused =
            skidbladnir::systemd::guard_chown_target(std::path::Path::new(deny), 110, None)
                .expect_err("the public guard must refuse a system directory");
        assert_eq!(refused.reason, ChownRefusal::SystemDir, "{deny}");
        assert!(
            skidbladnir::systemd::chown_recursive(std::path::Path::new(deny), 110, 110).is_err(),
            "the public chown_recursive must refuse {deny}"
        );
    }
}

/// `Storage` + a stateful spec render a StatefulSet+PVC purely through public API.
#[test]
fn public_k8s_stateful_storage() {
    let spec = ServiceSpec::new("db", "db", "/unused")
        .image("postgres:16")
        .stateful(true)
        .storage(Storage::new("8Gi", "/var/lib/postgresql/data"))
        .socket(Socket::tcp(5432));
    let r = renderer_for(Backend::Kubernetes).unwrap().render(&spec).unwrap();
    assert!(
        r.file_ending("db-statefulset.yaml")
            .unwrap()
            .contains("kind: \"StatefulSet\"")
    );
    assert!(
        r.file_ending("db-pvc.yaml")
            .unwrap()
            .contains("storage: \"8Gi\"")
    );
}

/// `config::LakeSpec` (the whole-lake descriptor) is public and derives ordered
/// start waves from declared dependency edges.
#[test]
fn public_lakespec_start_waves() {
    let lake = skidbladnir::config::LakeSpec {
        version: "20250712".into(),
        pki: skidbladnir::pki::PkiSource::SelfGenerated,
        products: vec![
            skidbladnir::product::ComponentSpec::new("holger", "linux-x86_64/holger", "_holger"),
            skidbladnir::product::ComponentSpec::new(
                "nornir-server",
                "linux-x86_64/nornir-server",
                "nornir",
            )
            .after("holger"),
        ],
    };
    let waves = lake.start_waves().unwrap();
    assert_eq!(waves[0], vec!["holger"], "root first");
    assert_eq!(waves[1], vec!["nornir-server"], "dependent after");
}

// ─────────────────────── THE UNINSTALL LAW, from outside ────────────────────

/// **THE UNINSTALL LAW through the PUBLIC surface only** — an external consumer
/// (nornir · holger · dwarves · korp) declares ONE data root and gets the law for
/// free: `uninstall` erases the data, keeps the ssh key + token, and reports honestly.
///
/// This file cannot reach `pub(crate)` internals, so it pins the exact API those four
/// consumers link against: `uninstall::{DataRoot, DataRootRefusal, UninstallOptions,
/// UninstallReport, StatePlan, EraseMode, EraseOwner, plan_state_erase,
/// execute_state_erase, execute_state_erase_in}` plus
/// `ServiceSpec::{data_root, legacy_data_root, state_dir}`
/// and `Lifecycle::{state_plan, uninstall_with}`. If any of these stop being public
/// (or change shape) this test fails to COMPILE — the honest early warning.
#[test]
fn public_uninstall_law_one_data_root_data_dies_keys_live() {
    use skidbladnir::uninstall::{
        DataRoot, EraseOwner, StatePlan, UninstallOptions, UninstallReport, execute_state_erase,
    };

    let d = tempfile::tempdir().unwrap();
    let root = d.path().join("var/lib/dwarves");
    std::fs::create_dir_all(root.join(".ssh")).unwrap();
    std::fs::create_dir_all(root.join("warehouse")).unwrap();
    std::fs::write(root.join(".ssh/id_ed25519"), b"DEPLOY KEY").unwrap();
    std::fs::write(root.join("token"), b"TOKEN").unwrap();
    std::fs::write(root.join("warehouse/facts.parquet"), b"DATA").unwrap();

    // A consumer declares ONE root. It is validated where it is written.
    let declared = DataRoot::new(&root).expect("a product's own directory is a valid data root");
    let spec = ServiceSpec::new("dwarves", "Dwarves symbol forge", "/usr/local/bin/dwarves")
        .user("dwarves")
        .working_dir("/var/lib/dwarves")
        .data_root(&declared)
        .legacy_data_root("/home/dwarves/.dwarves");
    assert_eq!(
        spec.data_root.as_deref(),
        Some(declared.path().to_str().unwrap())
    );

    // The plan the lifecycle would execute — assertable without root, without deleting.
    let lc = Lifecycle::new(spec.clone());
    let plan = lc.state_plan();
    assert_eq!(plan.root.as_deref(), Some(declared.path()));
    assert!(plan.erases_anything());

    // …and the SAME plan under the other scope, for ANY owning account. The owner,
    // 2026-08-03: *"Regardless of user install or system-user install (the owner calls that
    // global) — we made them the same."* The WHOLE plan is compared, not just the
    // root: a mode or legacy list that moved with the scope would be the same two-
    // ladder defect one level down. And the account is varied across a service name, a
    // human's name and none at all, because `--run-as <a-human>` is precisely the input
    // that made korp resolve a different tree and report a clean box over 113 GB.
    for user in [Some("dwarves"), Some("alice"), Some("root"), None] {
        let mut s = spec.clone();
        if let Some(u) = user {
            s = s.user(u);
        }
        let sys = Lifecycle::new(s.clone().scope(Scope::System)).state_plan();
        let usr = Lifecycle::new(s.scope(Scope::User)).state_plan();
        assert_eq!(
            sys, usr,
            "the erase plan must be IDENTICAL in both scopes (user {user:?}) — the data \
             root is a declaration, not a function of who owns the process"
        );
        assert_eq!(
            sys.root.as_deref(),
            Some(declared.path()),
            "…and it must be the DECLARED root, not merely equal to itself (user {user:?})"
        );
        assert!(
            sys.erases_anything(),
            "…and it must still erase (user {user:?})"
        );
    }

    // Execute it: DATA DIES, KEYS LIVE.
    let mut report = UninstallReport::new("dwarves");
    execute_state_erase(
        &StatePlan::sweep(&root),
        &EraseOwner::uid(skidbladnir::systemd::current_euid()),
        UninstallOptions::new(),
        &mut report,
    );
    assert!(!root.join("warehouse").exists(), "the data must be GONE");
    assert_eq!(
        std::fs::read(root.join(".ssh/id_ed25519")).unwrap(),
        b"DEPLOY KEY"
    );
    assert_eq!(std::fs::read(root.join("token")).unwrap(), b"TOKEN");
    assert!(!report.is_noop());
    assert!(report.summary().contains("KEPT"), "{}", report.summary());
}

/// A data root that IS a real user's home is refused where it is WRITTEN, through the
/// public constructor — a consumer cannot build a spec that would sweep somebody's
/// home. And the account-removal opt-in is refused without the identity opt-in.
#[test]
fn public_data_root_refuses_a_users_home_and_the_holger_combination() {
    use skidbladnir::uninstall::{DataRoot, DataRootRefusal, UninstallOptions};

    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
    let e = DataRoot::new(&home).expect_err("a real user's home is never a data root");
    assert!(matches!(
        e.reason,
        DataRootRefusal::IsUserHome { .. } | DataRootRefusal::SystemDir
    ));
    assert!(format!("{e}").contains("REFUSING"), "{e}");

    DataRoot::new("/").expect_err("the filesystem root is never a data root");
    DataRoot::new("relative").expect_err("a relative path is never a data root");

    UninstallOptions::new()
        .remove_account(true)
        .validate()
        .expect_err("removing the account that owns the key, without wiping it, is REFUSED");
}