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, ®istry, &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)"),
}
}