src/bifrost.rs
//! **Bifröst** — the deploy bridge to production for `nornir-server`.
//!
//! *Bifröst is the burning rainbow bridge to Asgard; Skíðblaðnir is the ship that
//! carries what crosses it.* This module is the bridge; the ship is the rest of
//! this crate.
//!
//! # Why this is a MODULE and not a binary
//!
//! It used to be a standalone `bifrost` crate with its own `bifrost` binary. That
//! collided on `$PATH` with knut's `bifrost` (the rel2graph engine, `knut-bifrost`),
//! which ships the binary of that name. The NAME was never the problem — two
//! binaries called `bifrost` was. So the deploy bridge folds in here and is reached
//! as `skidbladnir bifrost deploy|rollback`, leaving the standalone `bifrost`
//! binary to knut. No collision, metaphor intact.
//!
//! # What was thrown away in the fold, and why
//!
//! The standalone tool drove the deploy by **shelling out**: `sudo -n systemctl
//! stop/start`, `cargo build`, `cp` the live binary to `nornir-server.prev`, and a
//! health check by spawning `nornir workspace ls`. Skidbladnir already does every
//! one of those things properly and had done for a while:
//!
//! | the old shell step | what replaces it here |
//! |---|---|
//! | `sudo -n systemctl stop/start` | [`crate::install::SystemdRefreshControl`] — systemd over **D-Bus**, zero-shell |
//! | `cp <bin> <bin>.prev` | generation directories + an atomic `current` symlink flip ([`crate::install::update_product`]) |
//! | restore `.prev` on failure | [`crate::install::refresh_product`]'s built-in auto-rollback on a failed health probe |
//! | `bifrost rollback` | [`crate::install::rollback_product`] — swap `current` back to the retained generation |
//! | spawn `nornir workspace ls` | the product's declared [`HealthCheck`](crate::product::HealthCheck), probed by the tested prober |
//!
//! So the fold PORTED NOTHING. A copy-to-`.prev` is strictly worse than a retained
//! generation (it keeps exactly one, it is not atomic, and a crash mid-copy leaves
//! neither binary intact), and shelling to `sudo` violates the zero-shell law this
//! crate is built on. What is actually added is the missing thing: a **named entry
//! point** that says "deploy *nornir-server*", so the operator does not have to
//! assemble a `RefreshRequest` by hand.
//!
//! `advisory-db` — the third verb of the old tool — is deliberately **not** here.
//! Refreshing a RustSec advisory mirror is a security-data concern, not a deploy
//! concern; it belongs with nornir's `security` module or dwarves. Dragging it in
//! would have made "deploy" mean two unrelated things.
//!
//! # What this module does NOT cover (measured, 2026-08-01)
//!
//! The fold is not a superset, so the old repo's binary was **kept** — renamed to
//! `nornir-deploy` so it can no longer collide on `$PATH`. Of the 11 steps that tool's
//! `deploy` performed, 6 have a successor here and 5 do not:
//!
//! | still only in `nornir-deploy` | why there is no successor |
//! |---|---|
//! | `git checkout <tag>` + `cargo build --release` of nornir | no verb here builds a product from source; a deploy starts from a bundle that already exists |
//! | `install -m755` the `nornir` operator **CLI** onto `$PATH` | generations live under `/opt/nordisk/software/…` behind `current`; nothing is placed on `$PATH` |
//! | `advisory-db` | see above — no home yet |
//!
//! The health probe also differs in strength: the old tool ran an authenticated
//! `nornir workspace ls`, this one probes [`HealthCheck::Tcp(7878)`](crate::product::HealthCheck)
//! — a successful connect, not a successful query. That is a weaker gate for the
//! auto-rollback to fire on, and worth naming rather than presenting as a pure upgrade.
//!
//! # Authenticity is opt-in and must be stated
//!
//! [`DeployRequest::trust_root`] is what makes the ed25519 signature check mandatory.
//! `None` verifies INTEGRITY only (manifest root + per-entry content hashes), which is
//! a real guarantee but not the one operators assume when they hear "signed bundle".
//! The CLI (`skidbladnir bifrost deploy --trust-root <ca-pubkey-hex>`) pins it, and
//! prints which of the two guarantees is in force before it acts — it used to hardcode
//! `None` while calling the argument "the signed payload".
use std::path::Path;
use anyhow::Result;
use crate::install::{
InstallOutcome, RefreshControl, RefreshOutcome, RefreshRequest, SystemdRefreshControl,
refresh_product, rollback_product,
};
use crate::layout::Layout;
use crate::product::ComponentSpec;
use crate::service::Backend;
/// The component this bridge deploys. Declared once in [`ComponentSpec`] so the
/// bridge and every other surface agree on the unit name, user, port and health
/// probe instead of re-describing them.
pub fn spec() -> ComponentSpec {
ComponentSpec::nornir_server()
}
/// What a deploy needs. Deliberately small: everything about *what nornir-server
/// is* comes from [`spec`], and everything about *how a refresh works* comes from
/// [`refresh_product`].
pub struct DeployRequest<'a> {
/// **The already-built artifact carrying the new `nornir-server`** — a packed
/// bundle, or one built executable. Required; a deploy never builds one. See
/// [`crate::install::UpgradeSource`].
pub source: &'a crate::install::UpgradeSource,
/// Where generations live (`<software>/<product>/<version>` + `current`).
pub layout: &'a Layout,
/// Verifies the bundle and unfolds it.
pub archive: &'a dyn crate::archive::Archive,
/// Pin the bundle's authenticity to this trust root. `None` still verifies
/// INTEGRITY (manifest + content hashes) but not authorship.
pub trust_root: Option<&'a crate::archive::TrustRoot>,
/// Service backend the unit runs on.
pub backend: Backend,
/// Filesystem root the unit files live under (`/` in prod, a tempdir in tests).
pub root: &'a Path,
/// nornir's ordered schema-migration chain (oldest → head), or `&[]` if none.
pub chain: &'a [String],
/// Runs each migration hop. `None` skips migration entirely.
pub runner: Option<&'a dyn crate::migrate::MigrateRunner>,
}
/// **Cross the bridge**: verify → unfold → migrate → flip `current` → restart →
/// health-probe, and **auto-roll-back to the previous generation if the new one is
/// unhealthy**.
///
/// The rollback is not best-effort bolt-on: it is [`refresh_product`]'s contract,
/// and [`RefreshOutcome::rolled_back`] reports honestly whether it fired. A deploy
/// that comes back `health_ok: false, rolled_back: true` is a SUCCESSFUL refusal —
/// production is still serving the old generation — not a silent failure.
///
/// Production drives it over [`SystemdRefreshControl`] (systemd D-Bus, zero-shell);
/// a test injects its own [`RefreshControl`] so the whole rollback path runs
/// offline. Use [`deploy_with`] for the latter.
pub fn deploy(req: &DeployRequest<'_>) -> Result<RefreshOutcome> {
let control = SystemdRefreshControl::new(spec(), req.layout.clone(), req.backend);
deploy_with(req, &control)
}
/// [`deploy`] with the restart/health side effects injected — the seam that makes
/// the auto-rollback path testable without a machine, a systemd, or a network.
pub fn deploy_with(
req: &DeployRequest<'_>,
control: &dyn RefreshControl,
) -> Result<RefreshOutcome> {
let spec = spec();
let request = RefreshRequest {
spec: &spec,
layout: req.layout,
source: req.source,
archive: req.archive,
trust_root: req.trust_root,
backend: req.backend,
root: req.root,
chain: req.chain,
runner: req.runner,
control,
};
refresh_product(&request)
}
/// **Walk back over the bridge**: point `current` at the previously-retained
/// generation and restart. The manual counterpart to [`deploy`]'s automatic
/// rollback, for when the new generation passed its health probe but is wrong
/// anyway.
///
/// Errors — rather than half-acting — when no prior generation is retained, and
/// leaves `current` untouched in that case.
pub fn rollback(layout: &Layout, backend: Backend, root: &Path) -> Result<InstallOutcome> {
rollback_product(&spec(), layout, backend, root)
}
#[cfg(test)]
mod tests {
use super::*;
/// The bridge deploys the SAME component every other surface describes — it
/// does not carry a second, drifting description of what nornir-server is.
#[test]
fn the_bridge_deploys_the_declared_nornir_server_component() {
let s = spec();
assert_eq!(s.name, "nornir-server", "the unit the bridge deploys");
assert_eq!(s, ComponentSpec::nornir_server(), "no second description");
assert_ne!(
s.health,
crate::product::HealthCheck::None,
"a deploy with no health probe cannot auto-roll-back — the probe IS the gate \
that decides whether the new generation stays"
);
}
/// **The fold's load-bearing claim, asserted:** this module drives the deploy
/// through Skidbladnir's own machinery and shells out for nothing. The old
/// standalone tool ran `sudo -n systemctl …`, `cargo build` and `cp`; if any of
/// that ever gets ported back in, this goes red.
#[test]
fn the_bridge_shells_out_for_nothing() {
let src = include_str!("bifrost.rs");
// Only the REAL code is under test: drop the module docs (which necessarily
// NAME the shell commands they replaced) and the test module below (which
// has to spell the banned words to look for them).
let real = src.split("#[cfg(test)]").next().unwrap_or("");
let code: String = real
.lines()
.filter(|l| !l.trim_start().starts_with("//!") && !l.trim_start().starts_with("///"))
.collect::<Vec<_>>()
.join("\n");
for banned in ["std::process::Command", "sudo", "systemctl", "cargo build"] {
assert!(
!code.contains(banned),
"the deploy bridge must not {banned:?} — that is what the fold removed \
(pure-rust-zero-shell-law)"
);
}
}
}