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

nordisk/holger-ui

crates/holger-ui-recipe/src/variants.rs

//! **The build variants — every `[[build.variant]]` holger-ui declares, as Rust.**
//!
//! The model: variant → features → settings/sub-settings → typ. Each row is one
//! `(package × bin × features × profile × target)` point with its artifact kind
//! (the typ), its linkage and its role.
//!
//! # Where these rows come from
//!
//! Not from a design. Every row below is a build this repository ALREADY
//! performs, read off the doors that perform it:
//!
//! * `Trunk.toml` — `[build] target = "index.html"`, `dist = "dist"`. The whole
//!   repository is one trunk bundle; there is no second `Trunk.toml`.
//! * `xtask/src/main.rs`'s `publish` — the only door that ships anything. It
//!   runs `trunk build --release --dist ~/.local/share/holger-console/dist`,
//!   hashes trunk's inline bootstrap into the CSP and restarts the unit.
//! * `README.md` §Release build — `trunk build --release && chmod -R a+rX dist`.
//! * `deploy/holger-console.container` — the quadlet that serves that `dist/`.
//!
//! There is NO CI workflow in this repository (no `.github/`), so no row may
//! cite one.
//!
//! # ★ The BFF row, and the day it stopped being an absence
//!
//! This module used to carry a long note under the heading "what is NOT here":
//! there was no `mvp-ui` row, because `crates/holger-ui-server` did not exist
//! and a row for it "would be a row for a build that CANNOT run — `cargo build
//! -p holger-ui-server` fails with package not found". It ended:
//!
//! > The BFF is owed — it is what a browser passkey ceremony needs, and holger's
//! > console has no browser session without it — but owing a crate and declaring
//! > its build are different acts, and only the second one lies.
//!
//! The crate exists now, so the declaration does. `mvp-ui` is
//! `holger-ui-server`: the axum backend-for-frontend that holds the passkey
//! ceremony, terminates a session cookie, and speaks native gRPC to holger
//! holding the console's own bearer. `tests/toml_parity.rs` carried the
//! assertion that forced this edit to happen WITH the crate rather than as a
//! copy of gunnar-ui's declaration describing nothing.
//!
//! **What the console reaching holger directly still means.** The wasm row's
//! `reaches = ["holger"]` is unchanged and is not now wrong: the bundle still
//! speaks gRPC-web to holger's tonic-web door for everything it reads. What the
//! BFF adds is the half a browser cannot do — the login ceremony, and a
//! credential a browser must never hold. Two rows, two artifacts, one product.
//!
//! New reasoning goes HERE, never into the emitted toml: `emit` overwrites it.

use std::collections::BTreeSet;

use nornir_build_thing::plugin::Role;
use nornir_build_thing::variant::{ArtifactKind, BuildVariant, Profile, ServiceShape};

/// The console package — the Leptos/WASM browser half, and today the whole of
/// this repository. Named `holger-ui-web` and not `holger-ui` on purpose: the
/// holger main repo's egui operator app owns that name (`holger-server-ui`
/// since 2026-08-01, `holger-ui` before), and the two must never collide in a
/// workspace or a patch table.
pub const WEB: &str = "holger-ui-web";

/// **The console's program** (`cargo install holger-ui`, `crates/holger-ui`) — the native
/// half, a thin crate over the `holger-ui-server` library. It serves `/login`, terminates
/// the passkey session, and speaks native gRPC (not gRPC-web) to holger.
pub const SERVER: &str = "holger-ui";

/// The browser target.
pub const WASM32: &str = "wasm32-unknown-unknown";

/// The BFF's target, gunnar-ui's triple. musl, so the machine carries no libc
/// version dependency on a distribution that will be upgraded under it — and
/// `cargo xtask front plan` step 15 has named this triple since it was written.
/// It named it against a row that said `None`, which is the drift this closes.
pub const MUSL: &str = "x86_64-unknown-linux-musl";

/// ⚠ **THE MUSL WALL — RE-MEASURED 2026-09-20 ON t14s, AND IT WAS loki's BOX,
/// NOT THIS ROW'S.**
///
/// The text that stood here said, dated 2026-09-19: *"no x86_64-linux-musl-gcc
/// on this box; zstd-sys and ring need one"*, and concluded that
/// `cargo build --release -p holger-ui-server --target x86_64-unknown-linux-musl`
/// CANNOT be performed. Two independent lanes measured that claim again on
/// 2026-09-20, on t14s, and it is false here. It is REPLACED rather than
/// deleted, because a measured claim is answered by a better measurement and
/// never by silence.
///
/// WHAT WAS MEASURED, 2026-09-20, t14s:
///
/// * `/usr/bin/x86_64-linux-musl-gcc` EXISTS (it is Debian's 106-byte shell
///   wrapper, the very one the old note called a trap), and
///   `x86_64-unknown-linux-musl` is in `rustup target list --installed`.
/// * `cargo build -p holger-ui-server --release --target x86_64-unknown-linux-musl`
///   finished in 2 m 21 s and produced a valid static-pie musl ELF.
/// * A probe crate on `zstd-sys` 2.1 + `ring` 0.17 linked for that triple in
///   40 s — the wrapper is enough for these two.
/// * **`ring` is not in `holger-ui-server`'s dependency graph at all**
///   (`cargo tree -p holger-ui-server -e normal` → 0 hits; `zstd-sys` → 1), so
///   half of the stated cause never applied to this row in the first place.
///
/// WHAT THAT DOES **NOT** LICENCE. "It works on t14s and not on loki" is
/// verdict-by-box, which is the one thing
/// `workspace_nordisk/nornir-workspace.toml` forbids by name: *"A build that
/// only works on one box is a bug, not a build."* Both measurements are
/// therefore measurements OF A BOX and neither is a verdict on the row. The
/// answer is not "build on the host because this host happens to be equipped";
/// the answer is that this row is built in the DECLARED container
/// (`[build.toolchain]` in `crate::toolchain`, entered by `cargo xtask build
/// --variant mvp-ui` through `cargo_container::build_variant_in_container`),
/// where `x86_64-unknown-linux-musl` is a pinned property of the image and no
/// box is asked for a C compiler at all.
///
/// The old note's two "ways out" (zig, `musl-tools`) are therefore both moot:
/// they were host remedies for a host road that no longer exists here.
pub const MUSL_WALL: &str = "re-measured 2026-09-20 on t14s: musl-gcc present, holger-ui-server \
                             builds for musl in 2m21s, and `ring` is not in its graph — the \
                             2026-09-19 wall was loki's box. The row builds in the declared \
                             container regardless, so no box decides it";

/// **The system user and unit name the BFF installs itself as.** `ArtifactKind::Service`
/// REQUIRES a member for exactly this identity: it is the account the unit runs
/// as, and it is what an uninstall knows to leave alone. It matches
/// `holger_ui_server::lifecycle::{UNIT, USER}` and a test holds the three to
/// each other.
pub const MEMBER: &str = "holger-ui";

/// The suite member the console drives — holger itself, by the name holger's
/// own declaration gives it (`member = "holger"` on its `mini` and `mvp-server`
/// rows). The wire is gRPC-web straight to the server's tonic-web door; there is
/// no BFF in between.
const HOLGER: &str = "holger";

/// **The delivery this console belongs to.** holger is the one product in the
/// house with two deliveries — MVP (the Secure Update Gateway) and ORIGINAL (the
/// full artifact registry), `holger/.nornir/SUITES.md` — and
/// `workspace_nordisk/.nornir/SUITE-MODEL.md` rules that EVERY row carries a
/// suite tag even where a repository has one delivery, so that one command shape
/// (`cargo xtask build --suite MVP`) works house-wide.
///
/// MVP and not ORIGINAL, and not both: this console's surface is the MVP
/// gateway's. Adding ORIGINAL here would be a claim that the browser console is
/// part of the full registry delivery, which nothing in this repository or in
/// holger's `SUITES.md` says — ORIGINAL's `ui` atom is `holger-server-ui`, the
/// egui app, not this bundle. `suites` is a list, so the day holger ships the
/// browser console inside ORIGINAL too, that is one word here.
pub const MVP: &str = "MVP";



/// **The paid half, in holger's own spelling** (A28 M13). `holger-ui-web`'s `metered`, the
/// same word holger-ui-server, holger and holger-estate-front use, and gunnar-ui's: one word
/// turns the paid console on everywhere, and a second spelling would let the bundle and the
/// server disagree about whether this box sells anything.
pub const METERED: &str = "metered";

/// One row, spelled with only the fields holger-ui's declaration uses.
struct Row {
    name: &'static str,
    /// The delivery: MVP, this repository's one (H5 added the open `ui` row to it).
    suite: &'static str,
    role: Role,
    is_default: bool,
    package: &'static str,
    bin: &'static str,
    kind: ArtifactKind,
    features: &'static [&'static str],
    /// `None` is the HOST target, and it is a value rather than an omission.
    ///
    /// ⚠ CORRECTED 2026-09-19. This field's note used to argue that the BFF row
    /// should stay `None` because "nothing in this repository cross-builds it,
    /// and gunnar-ui's musl triple is its deploy's fact and not ours". That was
    /// true of a repository with no deploy; it stopped being true the moment
    /// `cargo xtask front plan` step 15 said, in private-holger-ops, `build
    /// holger-ui-server for x86_64-unknown-linux-musl`. The deploy IS the door,
    /// the row is what the door reads, and a row that disagrees with the only
    /// plan that builds it is the row that is wrong.
    target: Option<&'static str>,
    /// Does this artifact install a unit and own a system account?
    /// `ArtifactKind::Service` requires a member naming that identity, and a row
    /// that claimed `Service` without one would oblige every consumer to look
    /// for a unit nothing creates.
    member: bool,
    reaches: &'static [&'static str],
}

impl Row {
    fn to_variant(&self) -> BuildVariant {
        BuildVariant {
            name: self.name.to_string(),
            package: Some(self.package.to_string()),
            bin: Some(self.bin.to_string()),
            features: self.features.iter().map(|f| (*f).to_string()).collect(),
            // NOT `no_default_features`. `holger-ui-web` declares no `[features]`
            // table at all, so `--no-default-features` would be a flag that
            // changes nothing while claiming the row decided something — and no
            // door that builds this passes it. The row describes the command
            // that runs.
            no_default_features: false,
            profile: Profile::Release,
            target: self.target.map(str::to_string),
            // A browser bundle is no system service: nothing installs a unit or
            // a system user for it. The quadlet in `deploy/` installs *nginx*,
            // which is a container image and not this build's artifact. The BFF
            // is the other case and says so with `member: true`.
            member: self.member.then(|| MEMBER.to_string()),
            // THIN: nothing here compiles a sibling in. The bundle is a client
            // that reaches a running holger.
            shape: ServiceShape::ThinStandalone,
            suites: vec![self.suite.to_string()],
            role: Some(self.role),
            is_default: self.is_default,
            kind: self.kind,
            reaches: self
                .reaches
                .iter()
                .map(|r| (*r).to_string())
                .collect::<BTreeSet<_>>(),
            ..BuildVariant::default()
        }
    }
}

/// **Every build variant holger-ui declares**, in declaration order.
///
/// Order matters: `BuildVariantSet.variants` is a `Vec`, the menu and the
/// per-role default groups iterate it, and the committed toml follows it.
fn rows() -> [Row; 4] {
    use ArtifactKind::{Exe, Service, Wasm};
    use Role::{Client, Server};
    [
        // ══ role: client — the console, the half a browser runs ════════════════
        //
        // `role = "client"` and NOT a new `role = "ui"`. The role axis already
        // has the word: `Role::Client` is "something a human or a script drives
        // AGAINST a server — a UI, a CLI", which is this bundle exactly. It is
        // not `Solo` (nothing is compiled in; without holger running the console
        // is a blank page) and not `Server` (it is deployed as files nginx hands
        // out, it opens no port and owns no lifecycle). gunnar-ui settled this
        // in the same words for the same artifact; no vocabulary was minted.
        //
        // `workspace_nordisk/holger/PLAN-2026-09-17-two-vm.md` proposes
        // `role = "ui"`. There is no `Role::Ui` in
        // `nornir_build_thing::plugin::Role` (`Server | Client | Solo`), so that
        // spelling does not parse; the plan is out of date.
        //
        // `linkage = "thin"` with `reaches = ["holger"]`. The wire is gRPC-web
        // rather than native gRPC, so this row uses `reaches` for its plain
        // meaning — a running holger is REQUIRED for this artifact to do
        // anything — and makes no claim about the transport. Saying nothing
        // would have been worse: an omitted linkage reads as `fat`, a positive
        // claim of self-containment nobody made.
        //
        // ⚠ **`kind = "wasm"` describes the CARGO build point, and the bundle is
        // built by trunk.** `trunk build --release` runs this row's cargo build
        // and then three more things the model has no word for: wasm-bindgen,
        // `wasm-opt`, and the `index.html` asset pipeline — and it writes
        // CONTENT-HASHED names (`holger-ui-web-<hash>_bg.wasm`, which
        // `xtask publish` globs for by that suffix). That is why no
        // `[[build.wasm]]` cell is declared: `BindgenStage` DERIVES its output
        // names from the package name (`holger_ui_web_bg.wasm`), so a cell would
        // name files trunk never writes. The absence is documented, tested
        // (`tests/toml_parity.rs`) and is the honest half of a real gap in the
        // shared vocabulary; a `TrunkStage` beside `BindgenStage` is the fix,
        // and minting one quietly here is not.
        //
        // The default of the client group because it is what a bare
        // `trunk build --release` produces, which is the only bundle any door in
        // this repository builds.
        Row {
            name: "mvp-ui-web",
            suite: MVP,
            role: Client,
            is_default: true,
            package: WEB,
            bin: WEB,
            kind: Wasm,
            features: &[],
            target: Some(WASM32),
            member: false,
            reaches: &[HOLGER],
        },
        // ══ role: client — the PAID console (A28 M13) ═════════════════════════════
        //
        // `mvp-ui-web-metered`: the same package and triple, with `metered`. It renders
        // "My plan", the topbar's way to buy, the "More room" panel, the admin's order
        // button and payments card, and its page (`index-metered.html`) also copies the
        // sign-up, welcome and order pages into `dist-metered/`. The open row above
        // renders no word about money and copies none of them.
        //
        // This is the bundle holger's paid front embeds (holger-estate-front's build.rs
        // reads `../../holger-ui/dist-metered` under its `metered`). Before this row the
        // two builds shared ONE bundle, so the open build shipped the paid pages; gunnar-ui
        // declares its `gunnar-ui-web-metered` row for the same reason.
        //
        // NOT the client group's default: a bare `trunk build --release` makes the open
        // bundle. And no `mvp-ui-metered` BFF row: holger-ui-server's `metered` is turned
        // on by holger-estate-front, which links it as a library; no door here builds a
        // metered BFF binary.
        Row {
            name: "mvp-ui-web-metered",
            suite: MVP,
            role: Client,
            is_default: false,
            package: WEB,
            bin: WEB,
            kind: Wasm,
            features: &[METERED],
            target: Some(WASM32),
            member: false,
            reaches: &[HOLGER],
        },
        // ══ role: server — the BFF, the half a host runs ═══════════════════════
        //
        // `role = "server"`: it opens a port, it holds state that outlives a
        // request (the passkey table and the session-MAC key, in a redb store at
        // `HOLGER_UI_STATE`), and it holds a credential. None of those is true
        // of the bundle above, and all three are what `Role::Server` means.
        //
        // ★ **`kind = "service"` since 2026-09-19 — THE DAY IT GREW A LIFECYCLE.**
        //
        // This row said `Exe` and gave its reason: gunnar-ui's two BFF rows are
        // `Service` with a `member` because that binary installs ITSELF through
        // skidbladnir, and this one had only `--env-template` and `--check-env`,
        // which write nothing. It ended: "`exe` is what is built; the day it
        // grows a lifecycle, this row changes with it."
        //
        // That day is this commit. `holger-ui-server --install` now mints the
        // unit and runs as the dedicated `holger-ui` account through
        // `holger_ui_server::lifecycle`, so `Service` is now what is built and
        // `member` names an identity something actually creates. The old note is
        // quoted rather than deleted because it was RIGHT, and a declaration
        // that flips without saying which fact changed is a declaration nobody
        // can audit.
        //
        // **`target` is MUSL**, and for the same reason: `cargo xtask front plan`
        // step 15 in private-holger-ops builds this binary for that triple, so
        // the door exists now and the row describes it.
        //
        // `linkage = "thin"` with `reaches = ["holger"]`: nothing is compiled
        // in. Without a reachable holger this binary still serves `/login` and
        // still logs somebody in — the ceremony is entirely its own — but every
        // `/api/` answer is a gateway refusal, so a running holger is required
        // for the artifact to do its job.
        //
        // The default of the server group because it is the only row in it, and
        // because `cargo build --release -p holger-ui-server` is the whole of
        // what any door here builds. holger-ui-server has a `metered` half since
        // A28 M13, and holger-estate-front turns it on as a library; no door in
        // this repository builds a metered BFF binary, so no row says one.
        Row {
            name: "mvp-ui",
            suite: MVP,
            role: Server,
            is_default: true,
            package: SERVER,
            bin: SERVER,
            kind: Service,
            features: &[],
            target: Some(MUSL),
            member: true,
            reaches: &[HOLGER],
        },
        // ══ the product `holger-ui` (lane HOL-PRODUCTS, 2026-10-30): the row = `cargo install` ══
        //
        // `holger-ui` — one of Holger's four products as plain cargo installs it:
        // `cargo install --locked --path crates/holger-ui`. The host target, the release
        // profile, no feature, kind `exe` (what lands in `~/.cargo/bin`).
        // `tests/ui_row_equals_cargo_install.rs` holds it to its line and its tree. holger's
        // own declaration names the same row in its product list. Not the (MVP, server)
        // default: that stays `mvp-ui`.
        Row {
            name: "holger-ui",
            suite: MVP,
            role: Server,
            is_default: false,
            package: SERVER,
            bin: SERVER,
            kind: Exe,
            features: &[],
            target: None,
            member: false,
            reaches: &[HOLGER],
        },
    ]
}

/// The declared variants as nornir-build-thing's declaration type.
#[must_use]
pub fn variants() -> Vec<BuildVariant> {
    rows().iter().map(Row::to_variant).collect()
}

/// Where the console bundle lands: `dist/` at the repository root, the
/// directory trunk writes (`Trunk.toml` `dist = "dist"`) and the one
/// holger's front reads (`holger-estate-front/build.rs`: the declared sibling
/// `../../holger-ui/dist`, embedded into the vm1 binary under feature
/// `console`). Without a row saying so the front medium carried the
/// console's API and `/login` but not the page.
pub const DIST: &str = "dist";

/// The page the bundle is assembled from, repository-relative. Its one
/// `<link data-trunk rel="rust" />` names the root package, `holger-ui-web`.
pub const INDEX: &str = "index.html";

/// Where the PAID console bundle lands (`mvp-ui-web-metered`): the directory holger's paid
/// front embeds. trunk writes it with `--dist dist-metered`, and names its page `index.html`
/// there as in `dist/`.
pub const DIST_METERED: &str = "dist-metered";

/// The paid bundle's page: the open page (`index.html`) plus the paid half's pages, which it
/// copies. It sits beside `Cargo.toml` as the open page does, so its `rel="rust"` names the
/// same package. `tests/open_bundle_speaks_no_money.rs` holds the two pages to each other.
pub const INDEX_METERED: &str = "index-metered.html";

/// **`[[build.web_app]]`** — the console's `dist/` built through the Build
/// Thing's web-app step (nornir-build-thing `web_app`): the `mvp-ui-web` cargo
/// build in the pinned container, wasm-bindgen as a library at the version the
/// lock pins, the page assembled in Rust. Claimed by the wasm row of the SAME
/// name.
#[must_use]
pub fn web_apps() -> Vec<nornir_build_thing::web_app::WebAppBuild> {
    vec![
        nornir_build_thing::web_app::WebAppBuild {
            name: "mvp-ui-web".to_string(),
            out_dir: DIST.to_string(),
            index: INDEX.to_string(),
        },
        nornir_build_thing::web_app::WebAppBuild {
            name: "mvp-ui-web-metered".to_string(),
            out_dir: DIST_METERED.to_string(),
            index: INDEX_METERED.to_string(),
        },
    ]
}