brokkr.toml
# gunnar CI/build pipeline — driven by the dwarves forge (brokkr descriptor +
# sindri scheduler). `dwarves validate` / `dwarves plan` / `dwarves run`.
#
# ══ THIS FILE IS A RESTORATION, NOT A NEW IDEA. ════════════════════════════════
#
# It existed once and was LOST. MEASURED 2026-09-17:
#
# /home/rickard/git/old/private-gunnar commit 03c34b0
# "the forge had no job for this repository, so nothing but a human ever
# built it" — added `brokkr.toml`
# …still present at that lineage's HEAD (48f3e85).
#
# The current gunnar is the CLEANSED RE-ROOT of that lineage, and the file did
# not come across. Checked, not assumed: across all 132 refs in this repository
# `git cat-file -e <ref>:brokkr.toml` hits ZERO times, and `git log --all --
# brokkr.toml` is empty. The only surviving copy on this box is an untracked
# stray in the pruned `gunnar-gc` worktree, byte-identical to the old lineage's.
#
# Two things say the absence was a loss and not a decision:
#
# 1. `xtask/src/publish.rs:103` still carries an EXCLUSIONS entry for
# `brokkr.toml` — *"the forge's build declaration: how this tree is built
# here, not what it is"*. The publisher is still written to keep a file the
# tree no longer has. That entry survived the re-root; the file did not.
# 2. `dwarves validate /home/rickard/git/gunnar` and `dwarves plan …` both
# exit 2 with `no brokkr.toml at /home/rickard/git/gunnar` (run 2026-09-17
# against dwarves 0.1.6 / 23b624b). holger, korp, nornir, edda, skade,
# tunnr, draupnir, knut, modgunn, ordning, Skidbladnir and znippy-zoomies
# all answer. gunnar was the hole.
#
# It is NOT a copy of the old file. That one described a repository with 8
# declared variants and a forge that could not assemble a medium; both facts
# have moved. What is restored is the job; the content is re-measured below.
#
# ══ WHAT THIS FILE ACTUALLY BUYS, AND WHAT IT DOES NOT ═════════════════════════
#
# ⚠ It is NOT a build path, and reading it as one is the mistake korp's own
# header had to spend forty lines undoing. The variants live in
# `.nornir/build-variants.toml` and nowhere else: a brokkr `variant`/`tests` step
# reads THE SAME FILE — `RealRunner::discover` → `BuildVariantSet::discover(
# repo_root)` (dwarves/crates/dwarves/src/runner.rs:186) — which is the same
# relationship `cargo xtask build` and `dwarves buildthing gunnar --variant …`
# already have to it. This introduces a second CALLER of the one build path, not
# a second path.
#
# What the absence actually cost, measured in dwarves' source:
#
# · `dwarves validate|plan|run <repo>` → `discover_pipeline` →
# `PipelineDescriptor::discover` (brokkr/descriptor.rs:56) reads
# `<repo_root>/brokkr.toml`. `Ok(None)` ⇒ the three verbs print
# `no brokkr.toml` and exit 2. gunnar had no plan to show.
# · `RegisterPipeline` on the server (src/server.rs:398) answers
# `registered:false, "no brokkr.toml at …"` for a repo without one, and that
# is the same answer `dwarves repo add <ssh-url>` relays (main.rs:9370).
# **So gunnar could not be a forge PROJECT at all** — no poll-on-push, no
# nightly, no DAG, no history rows, no badge.
# · `RunKind::Tests` exists ONLY inside a pipeline. `dwarves buildthing` and
# `cargo xtask build` both only BUILD. With no brokkr.toml there was no way
# to ask the forge to run gunnar's suites at the features gunnar ships —
# 836 `#[test]`/`#[tokio::test]` fns in the two packages the variants name
# (gunnar-server 507, gunnar-cli 329, counted 2026-09-17).
#
# ── ⚠ 243 ROWS IS NOT 243 VARIANTS ────────────────────────────────────────────
#
# `grep -c '^\[\[build\.' .nornir/build-variants.toml` counts every sugar
# section. The census on 2026-09-17:
#
# 19 [[build.variant]] — the ONLY rows a `run = {…}` step
# can name
# 224 sugar rows — 73 installer.params, 63
# installer.options.kernel_requires,
# 54 installer.requires, 18
# installer.options, 9 installer,
# 3 iso.options.kernel_requires,
# 1 each of iso, iso.options,
# container, container.layers
#
# of the 19 variants: 8 CARGO kinds (7 × exe + 1 × service)
# 11 PACKAGING (1 container, 1 iso, 9 installer)
#
# All 8 cargo rows are declared below. Every one of them. (The old file declared
# 5 of 5; the three metered/thin server rows did not exist yet.)
#
# ── ⚠ WHAT EACH STEP KIND ACTUALLY DOES (read before adding one) ──────────────
#
# run = { variant = "…" } → `RealRunner::run`, `RunKind::Variant` arm
# (runner.rs:590). Forks on the ROW'S OWN `kind`:
# a cargo kind goes to `build_one`, a packaging kind
# to `assemble_one`. **It is a BUILD. It runs no
# test.**
# run = { tests = "…" } → `RealRunner::test_one` (runner.rs:257): the SAME
# variant's `cargo_args()` with `build` swapped for
# `test` and `--bin`/`--lib` dropped, through
# `buildgate::run_cargo`. Same package, same
# features, same `--no-default-features`, same
# profile — a test run whose features could drift
# from the build's would be testing something the
# forge does not ship. It compiles AND runs, so it
# strictly SUBSUMES a `variant` step on the same row,
# which is why no row below gets both.
# run = { container = … } → `UnwiredRunner`. FAILS by design. Not declared.
# run = { vm = … } → `UnwiredRunner`. FAILS by design. Not declared.
#
# ⚠ gunnar's cargo steps do NOT run on the host. `.nornir/build-variants.toml`
# declares `[build.toolchain] image = "nordisk-cargo-toolchain"`, and
# `RealRunner::build_one` checks that FIRST (runner.rs:495): with a toolchain
# declared every cargo build goes to `build_one_in_container`. The doc comment on
# that fork names gunnar as the reason it exists — *"MEASURED on oden 2026-08-25,
# `dwarves buildthing gunnar --variant gunnar-server` → error[E0463]: can't find
# crate for core, FAILED in 1.8 s, because gunnar targets musl and that box's
# rustup had no musl std"*. This file inherits that and needs no opinion of its
# own about toolchains.
#
# ── ⚠ TWO THINGS ON THIS BOX WOULD STOP A `--real` RUN TODAY ──────────────────
#
# Neither is a property of this pipeline; both are recorded so the next red is
# read correctly instead of being blamed on the steps.
#
# 1. **The installed forge cannot read gunnar's declaration at all.** MEASURED
# 2026-09-17, `dwarves buildthing /home/rickard/git/gunnar --list` (dwarves
# 0.1.6):
#
# parse .nornir/build-variants.toml: parse [build] variants: TOML parse
# error at line 331 … unknown field `kernel_requires`, expected one of
# `name`, `label`, `default`, `feature`, …
#
# That is the exact failure `nornir-build-thing`'s own reader documents at
# `variant.rs:1723` ("measured on gunnar's committed recipe against
# published 0.1.10"). The edda tree on disk parses it; the installed binary
# predates the schema. Reinstall the forge — do not trim the recipe.
# 2. **The toolchain pin does not match the image on this box.**
# `[build.toolchain] image_sha256 = "3d5115ba…"`; the local
# `nordisk-cargo-toolchain:live` manifest digest is `2cf9b012…` and its id
# `72abf41e…`. `CargoToolchain::image_ref` resolves
# `nordisk-cargo-toolchain@sha256:3d5115ba…`, which is not present, so a
# container build would try to PULL. Moving that pin is a change to the
# recipe (code → emit), not to this file.
#
# ── HOW THE STEPS ARE GROUPED, AND WHY THAT AXIS ──────────────────────────────
#
# Not by suite: all 19 rows are `suites = ["ORIGINAL"]`, so that axis carries
# zero information here. The grouping is the one the recipe itself argues for in
# its own header — *"gunnar is a git client and a git server in ONE binary whose
# two halves have different constraints … which is why the server atom and the
# client atom are two build points and not one with a flag"* — plus the third
# shape that has an init under it:
#
# stage the boundary a red names
# appliance the PID-1 musl half. The ONLY cross-triple rows in the repo, so a
# red here with `clients` green names the musl toolchain and not
# gunnar. Also the only rows naming `dhcp-client`, `wg-client`,
# `replication`, `grow-runtime` and `metered` — five capabilities
# `[[build.installer.options]]` lets a site switch off.
# clients `-p gunnar-cli` on the host triple. A red here with `appliance`
# green is the client/control stack: tonic, prost, axum, hyper.
# test the suites, at the SAME features the artifacts are built with.
#
# Within a stage the CHEAPEST row is the root and the rest fan out from it, so a
# broken common core reds ONCE instead of eight times, and each `needs` edge is
# picked so the red BETWEEN two rows names the feature that separates them:
#
# server-thin the root: server + engine-znippy + mimalloc + dhcp-client +
# replication + grow-runtime
# server needs server-thin → `wg-client` alone
# metered-thin needs server-thin → `metered` alone
# metered needs server → `metered` on top of `wg-client`
# server-product `gunnar`, the product server (cargo install gunnar)
# control-client `gunnar-cli`, the product tool (cargo install gunnar-cli)
#
# ── AND WHICH ROWS RUN TESTS, WHICH ONLY BUILD ────────────────────────────────
#
# The rule, same as holger's and korp's: **run the tests of the WIDEST resolution
# in each family; BUILD the narrower rows, because what a narrower row asserts is
# a LINKAGE or feature-boundary claim, and that is a build property.**
#
# Three test steps for the build points, and the second is the one that is NOT
# obvious:
#
# server-tests `gunnar-server` the widest OPEN appliance resolution
# metered-tests `gunnar-server-metered` ⚠ NOT subsumed by server-tests, and
# not subsuming it either. `-p gunnar-server` splits its suite
# on `metered` in BOTH directions:
# tests/open_build_speaks_no_money.rs:221 and :675 gate two of
# that file's six tests on `#[cfg(not(feature = "metered"))]`
# — they compile only in the OPEN build;
# tests/disk_headroom.rs:53/57 carries both arms.
# Neither resolution can run the other's, so declaring one would
# leave a named set of tests compiled by nobody. The two `-thin`
# rows ARE subsumed: they differ from their roots by
# `wg-client`, and no test in the package is gated on it.
# cli-tests `gunnar-cli` = the product's `client,control`; the package's
# tests turn `server`, `engine-gix` and `full` on through its
# dev-dependency on itself, so this is the widest `-p gunnar-cli`
# surface there is. (`gunnar-solo` and `gunnar-service` were the
# rows here until GUN-PRODUCTS, 2026-10-30: both are `gunnar-cli`
# with features now, and `systemd` is compiled by `cargo xtask
# ci`'s `systemd-check` and `systemd-tests` steps.)
#
# ── WHAT IS DELIBERATELY NOT DECLARED, AND WHY (measured, not guessed) ────────
#
# **1. The 10 packaging rows.** `gunnar-image` (container), `gunnar-appliance-iso`
# (iso) and the eight installers (`gunnar-installer{,-open,-gce,-aws,-azure,
# -metered,-metered-aws,-metered-azure}`) get no step. (`gunnar-twin` is gone: a twin installs the primary's own medium and gets its role over the wire, `Bootstrap.BecomeTwin`.)
#
# ⚠ The OLD file's reason for this is now WRONG and must not be copied forward.
# It said *"nothing in dwarves calls `nornir_build_thing::assemble`"*, and that
# was true in August. It is not true now: `RealRunner::run` forks on the row's
# own kind and sends a non-cargo kind to `RealRunner::assemble_one` →
# `crate::assemble_cell` (runner.rs:596-600), the same door the ⚒ Build Thing
# pane uses, with the real bollard / pure-Rust-ISO / korp-installer builders
# injected. The route EXISTS.
#
# The reason they are still absent is a different and smaller one: the medium
# rows take an input this pipeline cannot obtain. `[build.iso.kernel_donor]`
# declares `repo = "tunnr", kernel = "tunnr-kernel"` — the ISO's kernel comes
# from ANOTHER REPOSITORY, and every installer chains that ISO. brokkr has a word
# for a cross-project input (`[[pipeline.depends]]`, `ArtifactDep`), but it is
# PARSED AND NEVER READ: outside `brokkr/model.rs` the only use of
# `ArtifactDep::artifacts` is `brokkr/artifact.rs:61`, and no planner or engine
# consults `pipeline.depends` at all. Declaring the chain here would therefore be
# decoration, and declaring the ISO step without it would red on a missing donor
# — a true failure about the wrong subject.
#
# Little is lost meanwhile, and it is the same argument the old file made: the
# container, the ISO and all eight installers take their payload from
# `gunnar-server` (`[[build.container.layers]] from_variant`, `[[build.iso]]
# from_variant`, `[[build.installer]] variant`), and that row is built AND tested
# below. The compilation every medium depends on is covered; only the packaging
# is not. The day `depends` resolves a donor, the steps belong here.
#
# **2. `artifacts = [...]` globs on the steps.** holger's file declares 14 of
# them; korp's declares none, and korp is right. `Step::artifacts` is parsed
# (brokkr/model.rs) and read by nothing — grep for it across
# `brokkr/{plan,dag,engine}.rs` returns zero. What actually flows downstream is
# `StepOutcome::with_artifact_files`, built from the RUNNER's own output list
# (runner.rs:618). A glob here would be a line that looks load-bearing and is
# not. It also could not be right on this box: builds land in
# `$CARGO_TARGET_DIR=/home/rickard/cargo-target`, not `<repo>/target`.
#
# **3. Most of the workspace, and that is not this file's fault.**
# `RunKind::Tests` is PACKAGE-SCOPED by construction — it inherits the variant's
# `cargo_args()`, which carries `-p <package>` — so a `tests` step runs exactly
# one package's suite. gunnar's 19 variants name TWO packages, `gunnar-server`
# and `gunnar-cli`. Everything in the other 17 crates is out of reach of any step
# this file could declare. Fixing that means `kind = "lib"` rows in
# `.nornir/build-variants.toml` (⚠ a `kind = "lib"` row with no `linkage` is
# REFUSED — an omitted linkage reads as `fat`; it wants
# `linkage = "not-applicable"`), which is a change to the delivery axis and
# belongs to a ruling, not to this pipeline.
#
# **4. `bench/`** — permanently, for a reason that is not a gap: it is a DETACHED
# crate with its own `[workspace]`, not a member, and no `[[build.variant]]`
# names it. There is nothing for a step to run. `[build.allow_unbuilt] stages`
# says the same thing in the recipe's own words.
[pipeline]
name = "gunnar-ci"
[[pipeline.trigger]] # fire when the poll sweep sees HEAD move
kind = "poll"
events = ["push"]
# …and nightly at 03:40 — gunnar's original slot, kept: off nornir 03:00,
# skidbladnir 03:20, holger 04:00, korp 04:40.
[[pipeline.trigger]]
kind = "cron"
cron = "40 3 * * *"
# ══ the appliance: the four musl rows every medium chains ══════════════════════
#
# `--no-default-features --target x86_64-unknown-linux-musl --profile release`,
# `-p gunnar-server`. First, and alone in its stage, because everything else in
# the repository is measured against it: xtask's `appliance` guard asserts a
# named set of crates is ABSENT from this exact resolution, and the container,
# the ISO and all eight installers take their payload from `gunnar-server`.
#
# ⚠ All four rows compile the same package to the same bin name, so they write
# the SAME path — `<target-dir>/x86_64-unknown-linux-musl/release/gunnar-server`
# — and each overwrites the last. That is a property of the recipe, not of this
# file; it is why the steps are ordered narrow → wide and why nothing here
# publishes a path (see NOT DECLARED #2).
[[pipeline.stage]]
name = "appliance"
[[pipeline.stage.step]]
name = "server-thin" # the family root: no wg-client, no metered
run = { variant = "gunnar-server-thin" }
[[pipeline.stage.step]]
name = "server" # ⭐ the shipped open appliance (+ wg-client)
needs = ["server-thin"]
run = { variant = "gunnar-server" }
[[pipeline.stage.step]]
name = "metered-thin" # server-thin + metered
needs = ["server-thin"]
run = { variant = "gunnar-server-metered-thin" }
[[pipeline.stage.step]]
name = "metered" # ⭐ the hosted appliance (wg-client + metered)
needs = ["server"]
run = { variant = "gunnar-server-metered" }
# ══ the client half: -p gunnar-cli on the host triple ══════════════════════════
#
# The products (GUN-PRODUCTS, 2026-10-30): `cargo install gunnar` and `cargo install
# gunnar-cli`, each its own row. Neither waits on the appliance: they share no build
# with it. (`gunnar-ui` is built from the gunnar-ui checkout, by its own pipeline.)
[[pipeline.stage]]
name = "clients"
[[pipeline.stage.step]]
name = "server-product" # `gunnar` — cargo install gunnar
run = { variant = "gunnar" }
[[pipeline.stage.step]]
name = "control-client" # `gunnar-cli` — git, crypt and the control plane
run = { variant = "gunnar-cli" }
# ══ the suites, at the SAME features the artifacts are built with ══════════════
[[pipeline.stage]]
name = "test"
[[pipeline.stage.step]]
name = "server-tests" # -p gunnar-server, the OPEN resolution
needs = ["server"]
run = { tests = "gunnar-server" }
[[pipeline.stage.step]]
name = "metered-tests" # the `metered` arm — see the rule above
needs = ["metered"]
run = { tests = "gunnar-server-metered" }
[[pipeline.stage.step]]
name = "cli-tests" # the widest -p gunnar-cli surface
needs = ["server", "control-client"]
run = { tests = "gunnar-cli" }