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

nordisk/Skidbladnir

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)"
            );
        }
    }
}