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(),
},
]
}