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

nordisk/Skidbladnir

src/bin/skidbladnir.rs

//! `skidbladnir` — a minimal CLI runner for RON orchestration flows plus the
//! opt-in `watch-refresh` HEAD-moved check.
//!
//! ```sh
//! skidbladnir run <flow.ron>                    # execute the flow, print each StepOutcome
//! skidbladnir run <flow.ron> --dry-run          # parse + print the plan, execute nothing
//! skidbladnir run <flow.ron> --work-dir <dir>   # fs root for install/migrate steps
//! skidbladnir watch-refresh <unit> [--repo <p>] # ONE HEAD-moved check → would-refresh report
//! skidbladnir --help
//! ```
//!
//! A THIN driver over [`skidbladnir::orchestration::run_flow`] (the `run` verb) and
//! [`skidbladnir::watch`] (the `watch-refresh` verb): it reads a `.ron`
//! [`Flow`] and runs it, or performs ONE git-`HEAD`-moved check and reports whether a
//! refresh WOULD fire. It adds NO orchestration logic of its own — the interpreter,
//! registry, vocabulary, and the whole poll/diff/stamp watch decision all live in the
//! library (`src/orchestration.rs`, `src/watch.rs`). Manual arg parsing (no clap),
//! matching the `openbao-provision` example's convention.
//!
//! ## `watch-refresh` is a VERB, not a live daemon
//! This verb performs exactly ONE check per invocation and exits — it NEVER spawns a
//! self-perpetuating loop and is NOT wired into any service startup. Turning the check
//! into a schedule is a DEPLOY decision the operator makes explicitly (the
//! [`skidbladnir::watch`] module renders a systemd `Type=oneshot` timer for that, and
//! offers a bounded in-process [`skidbladnir::watch::watch_refresh_loop`] for dev), and
//! is deliberately out of this bin's scope. Building the mechanism ≠ enabling it.

use std::path::PathBuf;
use std::process::exit;

use skidbladnir::orchestration::{Flow, Registry, RunContext, run_flow};

const USAGE: &str = "\
skidbladnir — RON orchestration flow runner + watch-refresh check + prefix-payload install

USAGE:
    skidbladnir run <flow.ron> [--dry-run] [--work-dir <dir>]
    skidbladnir pack-toolchain --toolchain <dir> --project <dir> --out <bundle> --version <ver> --host <triple> [--source <url>] [--product <name>]
    skidbladnir install <pkg> --bundle <file> [--prefix <dir>] [--version <ver>]
    skidbladnir dropin-check [<unit>...] [--unit-dir <dir>] [--passwd <file>] [--no-bus]
    skidbladnir rootless <account> [--apply]
    skidbladnir watch-refresh <unit> [--repo <dir>] [--data-dir <dir>] [--once] [--execute]
    skidbladnir bifrost deploy --bundle <file> [--trust-root <hex>] [--prefix <dir>] [--root <dir>]
    skidbladnir bifrost rollback [--prefix <dir>] [--root <dir>]
    skidbladnir --help

COMMANDS:
    run <flow.ron>        Parse a RON `Flow`, then run it on the native-default
                          registry, printing each StepOutcome in order.
    pack-toolchain        PACK an airgapped-Rust bundle (connected-host): `cargo vendor`
                          the project, stage the toolchain + vendor set + `.cargo/config`
                          + `manifest/{bundle,toolchain.pins,vendor.lock}` (sha256+blake3
                          per member), and SEAL it into a `.znippy` (needs --features
                          znippy for a real .znippy; else a .tar.zst). Sign the result
                          with the pki `sign_bundle` step (the target pins the CA).
    install <pkg>         Install a NON-SERVICE prefix payload (e.g. `rust-dev-rhel8`):
                          verify the bundle, unfold it to the prefix, activate `current`,
                          drop the rewritten `.cargo/config`, and emit the env script.
                          Delegates verified extraction to the archive engine (TarZstd,
                          or the real znippy engine for a `.znippy` when built with
                          --features znippy).
    dropin-check          READ-ONLY. For every installed unit: read its fragment, scan
                          its <unit>.service.d/ against what that fragment DECLARES,
                          and — when the bus is reachable — compare both against what
                          systemd actually RESOLVED (User/Group/Environment over D-Bus).
                          Names every contradiction with file + directive + winner.
                          Writes nothing, deletes nothing, touches no service. Exits 1
                          when any unit is overridden, so it can gate a deploy.
    rootless <account>    READ-ONLY by default. Report whether <account> can run
                          ROOTLESS containers: the newuidmap/newgidmap helpers, its
                          /etc/subuid + /etc/subgid grants, whether it lingers, and
                          whether /run/user/<uid> exists. Prints the exact mutations it
                          WOULD make and stops. With --apply it performs them — subid
                          grants by atomic rewrite (never `echo >>`), lingering over
                          org.freedesktop.login1 (never `loginctl`) — and refuses the
                          whole run if ANY step is unsupported on this host, because a
                          half-applied host setup is worse than none. Exits 1 when the
                          host is not ready, so it can gate a deploy.
    watch-refresh <unit>  ONE check: read git HEAD (gix, zero-shell), diff it against
                          the persisted watch-stamp, and REPORT whether a refresh would
                          fire. Read-only by default (prints \"would refresh\"; performs
                          NO reinstall). A single check that exits — NOT a live daemon.

`install` OPTIONS:
    --bundle <file>       The signed `.znippy` (or `.tar.zst`) payload bundle. REQUIRED.
    --prefix <dir>        Install prefix (holds `<version>/` + the `current` symlink).
                          Default: ~/.local/<pkg>.
    --version <ver>       Generation stamp for `software/<version>/`. Default: today's
                          YYYYMMDD.

`bifrost` OPTIONS:
    --bundle <file>       deploy only. The payload carrying the new nornir-server.
    --trust-root <hex>    deploy only. The trusted CA's ed25519 public key, 64 lowercase
                          hex chars, delivered OUT OF BAND. Pinning it makes the
                          bundle's signature MANDATORY. Omitted ⇒ integrity only
                          (manifest root + per-entry content hashes) and the deploy
                          SAYS so before acting — an unsigned bundle, or one signed by
                          anybody, installs.
    --prefix <dir>        Holds data/log/software + the `current` symlink. Default:
                          /opt/nordisk.
    --root <dir>          Filesystem root the unit files live under. Default: /.
                          A flag the verb cannot honour is an ERROR, not ignored:
                          `bifrost rollback --bundle …` is refused, because a rollback
                          returns to the retained generation and takes no bundle.

`dropin-check` OPTIONS:
    <unit>...             Units to audit (`korp-server` or `korp-server.service`).
                          None ⇒ every *.service fragment in --unit-dir (enablement
                          symlinks and `→ /dev/null` masks are skipped — an installer
                          never wrote those).
    --unit-dir <dir>      Where the fragments live. Default: /etc/systemd/system.
    --passwd <file>       passwd-format file the foreign-home rule reads.
                          Default: /etc/passwd.
    --no-bus              Skip the D-Bus applied-state comparison (file-only audit).
                          The default already degrades to this when no bus is
                          reachable, and SAYS so — a skipped comparison is never
                          reported as agreement.

`run` OPTIONS:
    --dry-run             Parse and print the plan (one line per step); execute nothing.
    --work-dir <dir>      Filesystem root for steps that write (InstallApp/Migrate).
                          Default: a fresh temp dir.

`watch-refresh` OPTIONS:
    --repo <dir>          Repo / working-copy to read HEAD from. Default: current dir.
    --data-dir <dir>      Product data root holding the watch-stamp
                          (<data-dir>/.watch-refresh-stamp.json). Omitted ⇒ no prior
                          rev is known, so the check reports a first observation.
    --once                Explicit single check. This is ALSO the default; the verb
                          NEVER loops. Accepted for clarity / operator intent.
    --execute             Attempt to drive the REAL refresh via the watch engine's
                          RefreshTrigger seam. The generic bin supplies no product
                          trigger (it cannot build+pack a signed bundle from HEAD), so
                          this exits with an honest error naming what a product binary
                          must wire. Requires the `watch-refresh` cargo feature.

    -h, --help            Print this help.";

fn main() {
    if let Err(e) = run() {
        eprintln!("skidbladnir: {e:#}");
        exit(1);
    }
}

/// `<version> (<git-hash>)` — the SAME string `--version` prints, stamped by the
/// pure-fs `build.rs` (build-version-githash-law: every binary says which commit
/// built it, orchestration runners included).
pub const VERSION_LINE: &str = concat!(
    env!("CARGO_PKG_VERSION"),
    " (",
    env!("SKIDBLADNIR_GIT_HASH"),
    ")"
);

fn run() -> anyhow::Result<()> {
    let mut args = std::env::args().skip(1);
    let cmd = match args.next() {
        // Short-circuit BEFORE the verb dispatch: `--version` would otherwise fall
        // into the unknown-command arm and exit non-zero.
        Some(c) if c == "--version" || c == "-V" => {
            println!("skidbladnir {VERSION_LINE}");
            return Ok(());
        }
        Some(c) => c,
        None => {
            eprintln!("{USAGE}");
            exit(2);
        }
    };
    if cmd == "-h" || cmd == "--help" {
        println!("{USAGE}");
        return Ok(());
    }
    let rest: Vec<String> = args.collect();
    match cmd.as_str() {
        "run" => cmd_run(rest),
        "pack-toolchain" => cmd_pack_toolchain(rest),
        "install" => cmd_install(rest),
        "dropin-check" => cmd_dropin_check(rest),
        "rootless" => cmd_rootless(rest),
        "watch-refresh" => cmd_watch_refresh(rest),
        "bifrost" => cmd_bifrost(rest),
        other => {
            eprintln!("skidbladnir: unknown command `{other}`\n\n{USAGE}");
            exit(2);
        }
    }
}

/// `run <flow.ron>` — parse a RON `Flow` and run it on the native-default registry.
fn cmd_run(rest: Vec<String>) -> anyhow::Result<()> {
    // `run` args: a single <flow.ron> path plus flags, in any order.
    let mut flow_path: Option<PathBuf> = None;
    let mut dry_run = false;
    let mut work_dir: Option<PathBuf> = None;
    let mut i = 0;
    while i < rest.len() {
        match rest[i].as_str() {
            "--dry-run" => dry_run = true,
            "-h" | "--help" => {
                println!("{USAGE}");
                return Ok(());
            }
            "--work-dir" => {
                i += 1;
                let p = rest
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--work-dir needs a path"))?;
                work_dir = Some(PathBuf::from(p));
            }
            other if other.starts_with('-') => anyhow::bail!("unknown option `{other}`"),
            other => {
                if flow_path.is_some() {
                    anyhow::bail!("unexpected extra argument `{other}`");
                }
                flow_path = Some(PathBuf::from(other));
            }
        }
        i += 1;
    }
    let flow_path =
        flow_path.ok_or_else(|| anyhow::anyhow!("`run` needs a <flow.ron> path\n\n{USAGE}"))?;

    let flow = Flow::from_ron_file(&flow_path)?;
    println!(
        "parsed {}: {} step(s)",
        flow_path.display(),
        flow.steps.len()
    );

    if dry_run {
        for (idx, step) in flow.steps.iter().enumerate() {
            println!("  [{idx}] {} — {step:?}", step.module_kind());
        }
        println!("(dry-run — nothing executed)");
        return Ok(());
    }

    // Real run: the batteries-included registry + a work_dir the fs-touching steps
    // (InstallApp/Migrate) require. Mint a temp dir when none was supplied, keeping
    // its guard alive for the whole run so it isn't cleaned out mid-flow.
    let registry = Registry::native_default();
    let tmp = match &work_dir {
        Some(_) => None,
        None => Some(
            tempfile::Builder::new()
                .prefix("skidbladnir-flow-")
                .tempdir()?,
        ),
    };
    let work = work_dir.unwrap_or_else(|| {
        tmp.as_ref()
            .expect("temp dir minted above")
            .path()
            .to_path_buf()
    });
    println!("work_dir: {}", work.display());

    let mut ctx = RunContext {
        work_dir: Some(work),
        ..Default::default()
    };
    let outcomes = run_flow(&flow, &registry, &mut ctx)?;

    for (idx, o) in outcomes.iter().enumerate() {
        println!(
            "  [{idx}] {} {} — {}",
            o.module_kind,
            if o.real { "(real)" } else { "(mock)" },
            o.detail
        );
        for (k, v) in &o.data {
            println!("        {k} = {v}");
        }
    }
    println!("done — {} step(s) executed", outcomes.len());
    Ok(())
}

/// `install <pkg> --bundle <file>` — install a non-service prefix payload.
///
/// A THIN driver over [`skidbladnir::pack_toolchain::pack_toolchain`]: gather the
/// connected-host inputs (an expanded rustup toolchain dir + a target project), pick
/// the archive engine for the output extension, and drive vendor → stage → pin →
/// seal. It adds NO pack logic of its own — the whole assembly lives in the library.
fn cmd_pack_toolchain(rest: Vec<String>) -> anyhow::Result<()> {
    use skidbladnir::pack_toolchain::{
        PackToolchainRequest, ToolchainBundleManifest, pack_toolchain,
    };

    let mut toolchain: Option<PathBuf> = None;
    let mut project: Option<PathBuf> = None;
    let mut out: Option<PathBuf> = None;
    let mut version: Option<String> = None;
    let mut host: Option<String> = None;
    let mut source: Option<String> = None;
    let mut product = "rust-dev-rhel8".to_string();
    let mut i = 0;
    while i < rest.len() {
        macro_rules! val {
            ($flag:literal) => {{
                i += 1;
                rest.get(i)
                    .ok_or_else(|| anyhow::anyhow!(concat!($flag, " needs a value")))?
                    .clone()
            }};
        }
        match rest[i].as_str() {
            "-h" | "--help" => {
                println!("{USAGE}");
                return Ok(());
            }
            "--toolchain" => toolchain = Some(PathBuf::from(val!("--toolchain"))),
            "--project" => project = Some(PathBuf::from(val!("--project"))),
            "--out" => out = Some(PathBuf::from(val!("--out"))),
            "--version" => version = Some(val!("--version")),
            "--host" => host = Some(val!("--host")),
            "--source" => source = Some(val!("--source")),
            "--product" => product = val!("--product"),
            other => anyhow::bail!("unknown option `{other}`"),
        }
        i += 1;
    }

    let toolchain = toolchain
        .ok_or_else(|| anyhow::anyhow!("pack-toolchain needs --toolchain <dir>\n\n{USAGE}"))?;
    let project = project
        .ok_or_else(|| anyhow::anyhow!("pack-toolchain needs --project <dir>\n\n{USAGE}"))?;
    let out =
        out.ok_or_else(|| anyhow::anyhow!("pack-toolchain needs --out <bundle>\n\n{USAGE}"))?;
    let version = version
        .ok_or_else(|| anyhow::anyhow!("pack-toolchain needs --version <ver>\n\n{USAGE}"))?;
    let host =
        host.ok_or_else(|| anyhow::anyhow!("pack-toolchain needs --host <triple>\n\n{USAGE}"))?;

    let mut manifest = ToolchainBundleManifest::new(&product, &version, &host)
        .with_component("rustc")
        .with_component("cargo")
        .with_component(format!("rust-std-{host}"));
    if let Some(s) = source {
        manifest = manifest.with_source(s);
    }

    let engine = archive_engine_for(&out)?;
    let scratch = tempfile::Builder::new()
        .prefix("skid-pack-toolchain")
        .tempdir()?;
    println!("skidbladnir pack-toolchain {product}:");
    println!("  toolchain {}", toolchain.display());
    println!("  project   {}", project.display());
    println!("  out       {}", out.display());
    println!("  version   {version}  host {host}");

    let req = PackToolchainRequest {
        toolchain_dir: toolchain,
        project_dir: project,
        manifest,
    };
    let o = pack_toolchain(&req, scratch.path(), &out, engine.as_ref())?;

    println!(
        "  staged + sealed {} members → {}",
        o.staging.files.len(),
        o.bundle.display()
    );
    println!(
        "  pinned          {} members (sha256+blake3) in {}",
        o.staging.pins.entries.len(),
        skidbladnir::pack_toolchain::PINS_REL
    );
    println!("  root_sha256     {}", o.archive_manifest.root_sha256);
    println!(
        "\nsign it for authenticity (the target pins your CA) with the pki `sign_bundle` step."
    );
    Ok(())
}

/// A THIN driver over [`skidbladnir::install::install_prefix_payload`]: resolve the
/// payload from the built-in prefix registry, pick the archive engine for the bundle,
/// and drive the verify → unfold → activate → drop-config → env-script activation.
/// It adds NO install logic of its own — the whole lifecycle lives in the library.
fn cmd_install(rest: Vec<String>) -> anyhow::Result<()> {
    use skidbladnir::layout::Layout;
    use skidbladnir::product::{lookup_prefix, prefix_registry};

    let mut pkg: Option<String> = None;
    let mut bundle: Option<PathBuf> = None;
    let mut prefix: Option<PathBuf> = None;
    let mut version: Option<String> = None;
    let mut i = 0;
    while i < rest.len() {
        match rest[i].as_str() {
            "-h" | "--help" => {
                println!("{USAGE}");
                return Ok(());
            }
            "--bundle" => {
                i += 1;
                bundle = Some(PathBuf::from(
                    rest.get(i)
                        .ok_or_else(|| anyhow::anyhow!("--bundle needs a path"))?,
                ));
            }
            "--prefix" => {
                i += 1;
                prefix = Some(PathBuf::from(
                    rest.get(i)
                        .ok_or_else(|| anyhow::anyhow!("--prefix needs a path"))?,
                ));
            }
            "--version" => {
                i += 1;
                version = Some(
                    rest.get(i)
                        .ok_or_else(|| anyhow::anyhow!("--version needs a value"))?
                        .clone(),
                );
            }
            other if other.starts_with('-') => anyhow::bail!("unknown option `{other}`"),
            other => {
                if pkg.is_some() {
                    anyhow::bail!("unexpected extra argument `{other}`");
                }
                pkg = Some(other.to_string());
            }
        }
        i += 1;
    }

    let pkg = pkg.ok_or_else(|| {
        let names: Vec<String> = prefix_registry().into_iter().map(|p| p.name).collect();
        anyhow::anyhow!(
            "`install` needs a <pkg> name (known: {})\n\n{USAGE}",
            names.join(", ")
        )
    })?;
    let payload = lookup_prefix(&pkg).ok_or_else(|| {
        let names: Vec<String> = prefix_registry().into_iter().map(|p| p.name).collect();
        anyhow::anyhow!(
            "unknown prefix payload `{pkg}` (known: {})",
            names.join(", ")
        )
    })?;
    let bundle = bundle
        .ok_or_else(|| anyhow::anyhow!("`install {pkg}` needs --bundle <file>\n\n{USAGE}"))?;
    if !bundle.is_file() {
        anyhow::bail!("bundle {} does not exist", bundle.display());
    }

    // Default prefix: ~/.local/<pkg>. Fall back to ./<pkg> if HOME is unset.
    let prefix = prefix.unwrap_or_else(|| {
        let base = std::env::var_os("HOME")
            .map(PathBuf::from)
            .unwrap_or_else(|| PathBuf::from("."));
        base.join(".local").join(&pkg)
    });

    // A Layout whose software_dir IS the install prefix; data/log are unused by the
    // non-service install path. `dated` stamps today's YYYYMMDD unless --version wins.
    let mut layout = Layout::dated(&prefix, &prefix, &prefix);
    if let Some(v) = version {
        layout.version = v;
    }

    let engine = archive_engine_for(&bundle)?;
    println!("skidbladnir install {pkg}:");
    println!("  bundle   {}", bundle.display());
    println!("  prefix   {}", prefix.display());
    println!("  version  {}", layout.version);

    let out = skidbladnir::install::install_prefix_payload(
        &payload,
        &layout,
        &bundle,
        engine.as_ref(),
        None,
    )?;

    println!(
        "  unfolded + activated `current` → {}",
        out.current.display()
    );
    for f in &out.files_written {
        println!("  wrote    {}", f.display());
    }
    let env_sh = out.current.join(payload.env_script_name());
    println!("\ndone. Use the airgapped toolchain with:");
    println!("  source {}", env_sh.display());
    if !out.verify_cmd.is_empty() {
        println!("offline self-test (opt-in): {}", out.verify_cmd.join(" "));
    }
    Ok(())
}

/// Pick the [`skidbladnir::archive::Archive`] engine for `bundle`. A real `.znippy`
/// needs the znippy engine (feature-gated); anything else uses the always-on
/// `TarZstd`. A `.znippy` without the feature is an HONEST bail (no silent
/// wrong-engine attempt), naming the rebuild flag.
fn archive_engine_for(
    bundle: &std::path::Path,
) -> anyhow::Result<Box<dyn skidbladnir::archive::Archive>> {
    let is_znippy = bundle.extension().and_then(|e| e.to_str()) == Some("znippy");
    if is_znippy {
        #[cfg(feature = "znippy")]
        {
            return Ok(Box::new(skidbladnir::archive::ZnippyArchive::new()));
        }
        #[cfg(not(feature = "znippy"))]
        {
            anyhow::bail!(
                "bundle {} is a .znippy but this build has no znippy engine. \
                 Rebuild: cargo build --bin skidbladnir --features znippy",
                bundle.display()
            );
        }
    }
    Ok(Box::new(skidbladnir::archive::TarZstd::new()))
}

/// `rootless <account> [--apply]` — **the host setup nobody should type again.**
///
/// On oden, 2026-08-11, getting `korp-server` (`User=korp`, uid 111, `nologin`) a
/// podman socket took five commands typed by hand at a terminal on one box:
/// `loginctl enable-linger korp`, `systemctl --user enable --now podman.socket`,
/// two `echo … >> /etc/sub{u,g}id`, and `podman system migrate`. Every one of them
/// is a HOST mutation no `ServiceSpec` can express, which is why they were typed —
/// and why they get typed again on the next box, from memory, at 23:00.
///
/// This verb is the whole thing as ONE command, and it is READ-ONLY unless asked.
/// The dry run is deliberately the default and deliberately the larger half: "is
/// this host already set up?" has to be answerable before "set it up", and the plan
/// it prints is the same [`skidbladnir::rootless::Change`] values `--apply`
/// executes, so what it says and what it does cannot drift.
///
/// It exits 1 when the host is not ready, which is what lets it gate a deploy
/// rather than merely inform one.
fn cmd_rootless(rest: Vec<String>) -> anyhow::Result<()> {
    let mut account: Option<String> = None;
    let mut do_apply = false;
    for a in &rest {
        match a.as_str() {
            "-h" | "--help" => {
                println!("{USAGE}");
                return Ok(());
            }
            "--apply" => do_apply = true,
            other if other.starts_with('-') => {
                anyhow::bail!("rootless: unknown flag `{other}`\n\n{USAGE}")
            }
            other => {
                if account.replace(other.to_string()).is_some() {
                    anyhow::bail!("rootless: one account at a time, got a second `{other}`");
                }
            }
        }
    }
    let account = account.ok_or_else(|| {
        anyhow::anyhow!("rootless: which account? e.g. `skidbladnir rootless korp`")
    })?;

    let report = skidbladnir::rootless::inspect(&account);
    print!("{}", report.render());

    if report.is_ready() {
        println!("\nrootless: `{account}` is ALREADY set up — nothing to do.");
        return Ok(());
    }

    let plan = report.plan();
    let blockers = report.blockers();
    if !plan.is_empty() {
        println!("\nWOULD CHANGE ({} mutation(s)):", plan.len());
        for c in &plan {
            println!("  - {}", c.describe());
        }
    }
    if !blockers.is_empty() {
        println!("\nCANNOT ({} step(s) this host or this crate will not do):", blockers.len());
        for b in &blockers {
            println!("  - [{}] {}: {}", b.state.label(), b.step.id(), b.state.detail());
        }
    }

    if !do_apply {
        println!(
            "\nrootless: READ-ONLY — nothing was changed. Re-run with --apply to perform \
             the {} mutation(s) above.",
            plan.len()
        );
        // Not-ready is a non-zero exit even in the read-only mode: that is the
        // property that lets a deploy gate on it.
        anyhow::bail!("`{account}` is not ready to run rootless containers");
    }

    let applied = skidbladnir::rootless::apply(&report)?;
    println!("\nAPPLIED:");
    for line in &applied.performed {
        println!("  - {line}");
    }
    // Re-inspect rather than assume. `/run/user/<uid>` in particular is created by
    // logind asynchronously after SetUserLinger, so the honest post-state is the one
    // read back off the host, not the one predicted from the plan.
    let after = skidbladnir::rootless::inspect(&account);
    println!("\nAFTER:\n{}", after.render());
    if !after.is_ready() {
        println!(
            "rootless: the mutations landed but `{account}` is not fully ready yet. \
             /run/user/<uid> appears once logind acts on the linger — re-run this verb \
             to confirm."
        );
    }
    Ok(())
}

/// `dropin-check [unit…]` — **the read-only drop-in audit.**
///
/// Until this verb existed the check in [`skidbladnir::dropin`] could only run as a
/// side effect of an install: it needs a declaration to compare against, and the only
/// thing that held one was the installer that had just rendered it. So the one moment
/// you could learn that a unit is being silently overridden was the moment you were
/// changing it — never on a running box, months later, changing nothing.
///
/// This verb closes that by reconstructing the declaration from the INSTALLED
/// fragment ([`skidbladnir::dropin::declared_from_unit_file`]) and running the SAME
/// scanner over the SAME `<unit>.service.d/`. It adds no rules of its own.
///
/// It also asks systemd what it actually RESOLVED (`User`/`Group`/`Environment` over
/// D-Bus) and prints that beside the fragment's claim — the backstop for the
/// drop-in sources this scan cannot see (`/run/systemd/system/<unit>.d/`,
/// `/usr/lib/systemd/system/<unit>.d/`, `.control/`, type-wide `service.d/`). When
/// the bus is unreachable the comparison is SKIPPED and said so; it is never reported
/// as agreement.
///
/// Exit code 1 when any audited unit is overridden — the same RED the install
/// surface goes.
fn cmd_dropin_check(rest: Vec<String>) -> anyhow::Result<()> {
    use skidbladnir::dropin::{audit_unit_in, unit_files_in};

    let mut units: Vec<String> = Vec::new();
    let mut unit_dir = PathBuf::from("/etc/systemd/system");
    let mut passwd = PathBuf::from("/etc/passwd");
    let mut use_bus = true;
    let mut i = 0;
    while i < rest.len() {
        match rest[i].as_str() {
            "-h" | "--help" => {
                println!("{USAGE}");
                return Ok(());
            }
            "--no-bus" => use_bus = false,
            "--unit-dir" => {
                i += 1;
                unit_dir = PathBuf::from(
                    rest.get(i)
                        .ok_or_else(|| anyhow::anyhow!("--unit-dir needs a path"))?,
                );
            }
            "--passwd" => {
                i += 1;
                passwd = PathBuf::from(
                    rest.get(i)
                        .ok_or_else(|| anyhow::anyhow!("--passwd needs a path"))?,
                );
            }
            other if other.starts_with('-') => anyhow::bail!("unknown option `{other}`"),
            // A bare name, with or without the suffix.
            other => units.push(match other.ends_with(".service") {
                true => other.to_string(),
                false => format!("{other}.service"),
            }),
        }
        i += 1;
    }

    let named = !units.is_empty();
    if !named {
        units = unit_files_in(&unit_dir);
    }
    if units.is_empty() {
        anyhow::bail!("no *.service fragments found in {}", unit_dir.display());
    }

    println!("skidbladnir dropin-check — READ-ONLY (writes nothing, restarts nothing)");
    println!("  unit-dir {}", unit_dir.display());
    println!("  passwd   {}", passwd.display());

    // ONE bus connection for the whole run. A failure here is NOT fatal: the
    // file-level audit is the substance, and the applied-state comparison is a
    // backstop. But it must be NAMED, or a skipped check reads as a passed one.
    let bus = match use_bus {
        false => Err("--no-bus (applied-state comparison deliberately skipped)".to_string()),
        true => skidbladnir::systemd::Systemd::connect().map_err(|e| format!("{e:#}")),
    };
    match &bus {
        Ok(_) => println!("  applied  systemd D-Bus (User/Group/Environment as RESOLVED)"),
        Err(why) => println!(
            "  applied  NOT COMPARED — {why}. The file audit below still stands; what \
             systemd resolved was not read, so this run cannot rule out an override \
             from /run, /usr/lib or a type-wide service.d/."
        ),
    }
    println!();

    let mut audited = 0usize;
    let mut with_dropins = 0usize;
    let mut overridden = 0usize;
    let mut applied_divergences = 0usize;
    let mut missing: Vec<String> = Vec::new();

    for unit in &units {
        let Some(a) = audit_unit_in(&unit_dir, unit, &passwd)? else {
            missing.push(unit.clone());
            continue;
        };
        audited += 1;
        let service = unit.trim_end_matches(".service");

        // A unit with nothing to say gets ONE quiet line, so a clean box is readable.
        if !a.scan.has_dropins() {
            let mut line = format!("  ✓ {unit}: no drop-in dir");
            if let Ok(sd) = &bus {
                match applied_user_line(sd, unit, &a.declared) {
                    Ok(Some(diverged)) => {
                        applied_divergences += 1;
                        line = format!("  ✗ {unit}: no drop-in dir, but {diverged}");
                    }
                    Ok(None) => {}
                    Err(e) => line.push_str(&format!(" (applied state unreadable: {e:#})")),
                }
            }
            println!("{line}");
            continue;
        }

        with_dropins += 1;
        if a.scan.has_conflicts() {
            overridden += 1;
            println!("  ✗ {unit}  [{}]", a.unit_path.display());
            println!("{}", indent(&a.scan.conflict_report(service)));
        } else {
            println!(
                "  ✓ {unit}: {} additive drop-in(s) in {}, no contradiction",
                a.scan.files.len(),
                a.scan.dir.display()
            );
            for f in &a.scan.files {
                println!(
                    "      + {}",
                    f.file_name().unwrap_or_default().to_string_lossy()
                );
            }
        }

        if let Ok(sd) = &bus {
            match applied_user_line(sd, unit, &a.declared) {
                Ok(Some(diverged)) => {
                    applied_divergences += 1;
                    println!("      ! APPLIED: {diverged}");
                }
                Ok(None) => println!("      applied state agrees with the fragment"),
                Err(e) => println!("      applied state unreadable: {e:#}"),
            }
        }
    }

    println!();
    if !missing.is_empty() && named {
        println!(
            "  {} named unit(s) not installed in {}: {}",
            missing.len(),
            unit_dir.display(),
            missing.join(", ")
        );
    }
    println!(
        "dropin-check: {audited} unit(s) audited, {with_dropins} with drop-ins, \
         {overridden} OVERRIDDEN, {applied_divergences} diverging from applied state."
    );

    if overridden > 0 || applied_divergences > 0 {
        // Non-zero so this can gate a deploy. The report above already named every
        // file and directive, so this line adds only the verdict.
        anyhow::bail!(
            "{overridden} unit(s) do not run as their own fragment declares. Nothing was \
             changed — edit or remove the named drop-in(s) if the fragment's values are \
             the ones you want."
        );
    }
    Ok(())
}

/// Ask systemd what it RESOLVED for `unit` and compare it with `declared`.
/// `Ok(None)` ⇒ they agree (or the unit is not loaded and reports nothing).
/// `Ok(Some(msg))` ⇒ a one-line description of the divergence.
///
/// This is the half `scan_dropins_in` structurally cannot do: it sees the ONE
/// directory next to the fragment, while systemd merges several. A `User=` that
/// diverges here with a clean scan means the override lives somewhere the scan does
/// not read.
fn applied_user_line(
    sd: &skidbladnir::systemd::Systemd,
    unit: &str,
    declared: &skidbladnir::dropin::Declared,
) -> anyhow::Result<Option<String>> {
    let mut parts: Vec<String> = Vec::new();

    if let Some(u) = sd.service_user(unit)? {
        if !declared.user.is_empty() && u != declared.user {
            parts.push(format!(
                "User= is `{u}`, the fragment declares `{}`",
                declared.user
            ));
        }
    }
    if let Some(g) = sd.service_group(unit)? {
        if !declared.group.is_empty() && g != declared.group {
            parts.push(format!(
                "Group= is `{g}`, the fragment declares `{}`",
                declared.group
            ));
        }
    }
    for kv in sd.service_environment(unit)? {
        let Some((k, v)) = kv.split_once('=') else {
            continue;
        };
        if let Some((_, dv)) = declared.env.iter().find(|(dk, _)| dk == k) {
            if dv != v {
                parts.push(format!("{k}= is `{v}`, the fragment declares `{dv}`"));
            }
        }
    }

    Ok(match parts.is_empty() {
        true => None,
        false => Some(parts.join("; ")),
    })
}

/// Indent a multi-line report block by four spaces so it nests under its unit.
fn indent(block: &str) -> String {
    block
        .lines()
        .map(|l| format!("    {l}"))
        .collect::<Vec<_>>()
        .join("\n")
}

/// `watch-refresh <unit>` — ONE git-HEAD-moved check → would-refresh report.
///
/// Read-only by default: it detects whether HEAD has moved since the persisted
/// watch-stamp and prints the verdict WITHOUT touching the service or the stamp. It runs
/// exactly once and exits — no loop, no daemon, nothing wired into startup.
fn cmd_watch_refresh(rest: Vec<String>) -> anyhow::Result<()> {
    let mut unit: Option<String> = None;
    let mut repo: Option<PathBuf> = None;
    let mut data_dir: Option<PathBuf> = None;
    let mut execute = false;
    let mut i = 0;
    while i < rest.len() {
        match rest[i].as_str() {
            "--once" => { /* the ONLY mode: a single check. Accepted for explicitness. */ }
            "--execute" => execute = true,
            "-h" | "--help" => {
                println!("{USAGE}");
                return Ok(());
            }
            "--repo" => {
                i += 1;
                let p = rest
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--repo needs a path"))?;
                repo = Some(PathBuf::from(p));
            }
            "--data-dir" => {
                i += 1;
                let p = rest
                    .get(i)
                    .ok_or_else(|| anyhow::anyhow!("--data-dir needs a path"))?;
                data_dir = Some(PathBuf::from(p));
            }
            other if other.starts_with('-') => anyhow::bail!("unknown option `{other}`"),
            other => {
                if unit.is_some() {
                    anyhow::bail!("unexpected extra argument `{other}`");
                }
                unit = Some(other.to_string());
            }
        }
        i += 1;
    }
    let unit =
        unit.ok_or_else(|| anyhow::anyhow!("`watch-refresh` needs a <unit> name\n\n{USAGE}"))?;
    let repo = repo.unwrap_or_else(|| PathBuf::from("."));

    // Read the current HEAD through the library's gix-backed reader (zero-shell). This
    // requires the `watch-refresh` cargo feature; without it there is no HEAD seam to
    // read, and the verb says so honestly rather than shelling `git`.
    let head = read_head(&repo)?;

    // The last rev this product was refreshed to, from the persisted stamp beside its
    // data root. No --data-dir ⇒ we cannot know a prior rev, so this is a first
    // observation (the same posture watch_refresh_once treats as "moved").
    let previous: Option<String> = match &data_dir {
        Some(d) => skidbladnir::watch::read_watch_stamp(d)?.map(|s| s.head),
        None => None,
    };

    println!("watch-refresh `{unit}`:");
    println!("  repo        {}", repo.display());
    println!("  HEAD        {head}");
    match &previous {
        Some(p) => println!("  last-refreshed {p}"),
        None => println!("  last-refreshed <none> (no watch-stamp; first observation)"),
    }

    let moved = previous.as_deref() != Some(head.as_str());
    if !moved {
        println!("  verdict     HEAD unchanged — no-op (no refresh needed)");
        return Ok(());
    }

    if !execute {
        // The read-only default: report that a refresh WOULD fire, change nothing.
        let from = previous.as_deref().unwrap_or("<none>");
        println!(
            "  verdict     HEAD moved {from} → {head} — WOULD refresh `{unit}` (dry-run; no reinstall, stamp untouched)"
        );
        println!(
            "  note        pass --execute to drive the real refresh via the watch engine's RefreshTrigger seam (requires a product trigger — see below)."
        );
        return Ok(());
    }

    // --execute: drive the REAL watch engine (watch::watch_refresh_once, the same atom
    // Lifecycle::watch_refresh delegates to). The generic bin supplies a trigger that
    // bails honestly, because building+packing a signed bundle from HEAD is
    // product-specific work this bin does not own. This EXERCISES the real seam (read
    // HEAD → diff stamp → invoke trigger) and leaves the stamp UNADVANCED on the bail —
    // no reinstall, no corruption.
    execute_watch_refresh(&unit, &repo, data_dir.as_deref())
}

// ───────────────────────── the gix HEAD seam (feature-gated) ─────────────────────────

/// Read HEAD via the library's gix-backed [`skidbladnir::watch::GitHead`] (zero-shell).
/// Behind the `watch-refresh` feature exactly as the library reader is; without it the
/// verb cannot read HEAD (and MUST NOT shell `git`), so it says so.
#[cfg(feature = "watch-refresh")]
fn read_head(repo: &std::path::Path) -> anyhow::Result<String> {
    use skidbladnir::watch::{GitHead, HeadSource};
    GitHead::new(repo).head()
}

#[cfg(not(feature = "watch-refresh"))]
fn read_head(_repo: &std::path::Path) -> anyhow::Result<String> {
    anyhow::bail!(
        "watch-refresh needs the `watch-refresh` cargo feature (the gix HEAD reader). \
         Rebuild: cargo build --bin skidbladnir --features watch-refresh"
    )
}

/// The `--execute` path: run ONE real [`skidbladnir::watch::watch_refresh_once`] tick.
#[cfg(feature = "watch-refresh")]
fn execute_watch_refresh(
    unit: &str,
    repo: &std::path::Path,
    data_dir: Option<&std::path::Path>,
) -> anyhow::Result<()> {
    use skidbladnir::watch::{GitHead, RefreshTrigger, WatchRequest, watch_refresh_once};

    let data_dir = data_dir.ok_or_else(|| {
        anyhow::anyhow!("--execute needs --data-dir <dir> (the watch-stamp is persisted there)")
    })?;

    /// The generic bin's trigger: it does NOT own the product's build-from-HEAD → pack
    /// → refresh path, so it refuses honestly. In a product binary this is where the
    /// real `install::refresh_product` (== `Lifecycle::refresh`) call lives.
    struct GenericBinTrigger<'a> {
        unit: &'a str,
    }
    impl RefreshTrigger for GenericBinTrigger<'_> {
        fn refresh(&self, previous: Option<&str>, head: &str) -> anyhow::Result<String> {
            let from = previous.unwrap_or("<none>");
            anyhow::bail!(
                "watch-refresh detected HEAD moved {from} → {head} for `{}`, but the generic \
                 `skidbladnir` bin cannot perform the refresh: building + packing a signed bundle \
                 from HEAD is product-specific. Wire `--watch-refresh` into the PRODUCT binary \
                 (e.g. korp-server / dwarves-full), which supplies a RefreshTrigger backed by its \
                 own `--refresh` path (skidbladnir::install::refresh_product / \
                 service::Lifecycle::refresh). The stamp is left UNADVANCED, so a retry is safe.",
                self.unit
            )
        }
    }

    let head_src = GitHead::new(repo);
    let trigger = GenericBinTrigger { unit };
    let req = WatchRequest {
        product: unit,
        head: &head_src,
        data_dir,
        trigger: &trigger,
    };
    let outcome = watch_refresh_once(&req)?;
    println!("  {}", outcome.summary);
    Ok(())
}

#[cfg(not(feature = "watch-refresh"))]
fn execute_watch_refresh(
    _unit: &str,
    _repo: &std::path::Path,
    _data_dir: Option<&std::path::Path>,
) -> anyhow::Result<()> {
    anyhow::bail!(
        "watch-refresh --execute needs the `watch-refresh` cargo feature. \
         Rebuild: cargo build --bin skidbladnir --features watch-refresh"
    )
}

/// `bifrost deploy|rollback` — **Bifröst**, the deploy bridge to production for
/// `nornir-server` (folded in from the standalone `bifrost` crate, whose binary
/// collided on `$PATH` with knut's `bifrost`).
///
/// A THIN driver, like every other verb here: the whole deploy — verify → unfold →
/// migrate → flip `current` → restart over D-Bus → health-probe → **auto-rollback
/// when the new generation is unhealthy** — lives in
/// [`skidbladnir::bifrost`]/[`skidbladnir::install`]. Nothing shells out.
///
/// # Two things this driver refuses to be quiet about
///
/// **A flag a verb cannot honour is an ERROR, not a shrug.** `--bundle` used to be
/// parsed for every verb and then used by only one, so
///
/// ```text
/// $ skidbladnir bifrost rollback --bundle release-0.5.0.znippy
/// skidbladnir: no prior generation retained for `nornir-server` to roll back to
/// ```
///
/// answered a question the operator had not asked. They asked to go back to a
/// specific bundle; the bundle was dropped on the floor and never mentioned.
///
/// **Authenticity is stated, never implied.** `refresh_product` verifies a signature
/// only when a [`TrustRoot`](skidbladnir::archive::TrustRoot) is pinned, and this
/// driver hardcoded `trust_root: None` while its own error text called the argument
/// "the signed payload". Integrity (manifest root + per-entry content hashes) was
/// checked; authorship was not. `--trust-root <ca-pubkey-hex>` now pins it, and when
/// it is absent the deploy says so in one line before it acts.
fn cmd_bifrost(rest: Vec<String>) -> anyhow::Result<()> {
    use skidbladnir::archive::{TarZstd, TrustRoot};
    use skidbladnir::layout::Layout;
    use skidbladnir::service::Backend;

    let verb = rest.first().map(String::as_str).unwrap_or("");
    if verb.is_empty() || verb == "-h" || verb == "--help" {
        println!("{USAGE}");
        return Ok(());
    }

    // What each verb can ACT on. Every option bifrost knows is listed under the verb
    // that uses it, so an option offered to the wrong verb is caught by name and
    // explained, rather than parsed into a variable nobody reads.
    let (allowed, unusable_hint): (&[&str], &[(&str, &str)]) = match verb {
        "deploy" => (&["--bundle", "--prefix", "--root", "--trust-root"], &[]),
        "rollback" => (
            &["--prefix", "--root"],
            &[
                (
                    "--bundle",
                    "a rollback returns to the previously RETAINED generation, which is \
                  already unfolded on disk — it takes no bundle. To put a specific bundle live, \
                  use `bifrost deploy --bundle <file>`",
                ),
                (
                    "--trust-root",
                    "nothing is verified during a rollback: the generation being \
                  restored was verified when it was installed. Pin the trust root on the DEPLOY \
                  that installs it",
                ),
            ],
        ),
        other => anyhow::bail!("bifrost: unknown verb `{other}` (expected deploy|rollback)"),
    };

    let mut bundle: Option<PathBuf> = None;
    let mut prefix: Option<PathBuf> = None;
    let mut root: Option<PathBuf> = None;
    let mut trust_root_hex: Option<String> = None;
    let mut i = 1;
    while i < rest.len() {
        let flag = rest[i].as_str();
        if flag.starts_with("--") && !allowed.contains(&flag) {
            if let Some((_, why)) = unusable_hint.iter().find(|(f, _)| *f == flag) {
                anyhow::bail!("`bifrost {verb}` cannot honour `{flag}`: {why}");
            }
        }
        match flag {
            "--bundle" => {
                i += 1;
                bundle = Some(PathBuf::from(
                    rest.get(i)
                        .ok_or_else(|| anyhow::anyhow!("--bundle needs a path"))?,
                ));
            }
            "--trust-root" => {
                i += 1;
                trust_root_hex = Some(
                    rest.get(i)
                        .ok_or_else(|| {
                            anyhow::anyhow!(
                                "--trust-root needs the trusted CA's ed25519 public key as 64 \
                                 lowercase hex chars (its fingerprint, delivered out-of-band)"
                            )
                        })?
                        .clone(),
                );
            }
            "--prefix" => {
                i += 1;
                prefix = Some(PathBuf::from(
                    rest.get(i)
                        .ok_or_else(|| anyhow::anyhow!("--prefix needs a path"))?,
                ));
            }
            "--root" => {
                i += 1;
                root = Some(PathBuf::from(
                    rest.get(i)
                        .ok_or_else(|| anyhow::anyhow!("--root needs a path"))?,
                ));
            }
            "-h" | "--help" => {
                println!("{USAGE}");
                return Ok(());
            }
            other => anyhow::bail!("bifrost {verb}: unknown option `{other}`"),
        }
        i += 1;
    }

    // One prefix, the three standard roots under it — through `Layout::under`, the
    // ONE writer of that triple, so a deploy and an install disagree about nothing
    // (this used to be a fourth hand-written copy of the same three joins).
    let prefix = prefix.unwrap_or_else(|| PathBuf::from("/opt/nordisk"));
    let layout = Layout::under(&prefix);
    let root = root.unwrap_or_else(|| PathBuf::from("/"));
    let backend = Backend::SystemdSystem;

    // A pinned trust root makes the ed25519 signature check MANDATORY inside
    // `fetch_and_verify`. Decoding here (not deep in the deploy) means a typo in the
    // fingerprint fails before the unit is touched, with the expected form named.
    let trust_root = match &trust_root_hex {
        None => None,
        Some(h) => {
            let bytes = hex::decode(h.trim()).map_err(|e| {
                anyhow::anyhow!(
                    "--trust-root `{h}` is not hex ({e}) — pass the trusted CA's ed25519 public \
                     key as 64 lowercase hex chars"
                )
            })?;
            let pk: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
                anyhow::anyhow!(
                    "--trust-root must be a 32-byte ed25519 public key (64 hex chars); got {} \
                     bytes",
                    bytes.len()
                )
            })?;
            Some(TrustRoot::from_pubkey_bytes(&pk))
        }
    };

    match verb {
        "deploy" => {
            let bundle = bundle.ok_or_else(|| {
                anyhow::anyhow!(
                    "bifrost deploy needs --bundle <file> (the payload; add --trust-root \
                     <ca-pubkey-hex> to also verify WHO signed it)"
                )
            })?;
            // Say which of the two guarantees is actually in force, BEFORE acting.
            // Silence here read as "signed and verified" because the missing-arg
            // message called the bundle "the signed payload" while `trust_root` was
            // hardcoded `None`.
            match &trust_root {
                Some(tr) => eprintln!(
                    "bifrost deploy: authenticity PINNED to CA {} — a bundle signed by any \
                     other key, or unsigned, is refused",
                    tr.fingerprint()
                ),
                None => eprintln!(
                    "bifrost deploy: authenticity NOT verified — no --trust-root pinned, so \
                     only INTEGRITY is checked (manifest root + per-entry content hashes). An \
                     unsigned bundle, or one signed by anybody, will install."
                ),
            }
            let archive = TarZstd::new();
            let source = skidbladnir::install::UpgradeSource::at(&bundle)?;
            let req = skidbladnir::bifrost::DeployRequest {
                source: &source,
                layout: &layout,
                archive: &archive,
                trust_root: trust_root.as_ref(),
                backend,
                root: &root,
                chain: &[],
                runner: None,
            };
            let out = skidbladnir::bifrost::deploy(&req)?;
            println!(
                "bifrost deploy: {} {} → {}",
                out.product,
                out.from_version.as_deref().unwrap_or("(none)"),
                out.to_version
            );
            println!(
                "  health_ok={}  rolled_back={}",
                out.health_ok, out.rolled_back
            );
            if out.rolled_back {
                // An honest refusal, not a crash: production is still on the old
                // generation. Exit non-zero so a script cannot read it as success.
                anyhow::bail!(
                    "the new generation failed its health probe and was ROLLED BACK — \
                     production is serving {} again",
                    out.from_version
                        .as_deref()
                        .unwrap_or("the previous generation")
                );
            }
            Ok(())
        }
        "rollback" => {
            let out = skidbladnir::bifrost::rollback(&layout, backend, &root)?;
            println!("bifrost rollback: {} → {}", out.product, out.version);
            Ok(())
        }
        // Unreachable: the verb was already validated when its allowed flags were
        // looked up, so there is no second place a verb name can be accepted.
        other => anyhow::bail!("bifrost: unknown verb `{other}` (expected deploy|rollback)"),
    }
}