crates/nornir-build-thing/src/container_road.rs
//! **The container road, whole** — enter the pinned `[build.toolchain]` image once,
//! build a declared cargo row in it, and run the row's `[[build.wasm]]` stages
//! (wasm-bindgen, the component manifest) in the SAME image.
//!
//! THE LAW: *a build thing builds in a container or a KVM, never on the host.*
//! [`build_variant_in_container`](crate::cargo_container::build_variant_in_container)
//! already compiled in the image, but a wasm row is not done when cargo exits: its
//! bindgen and manifest stages ran through the one [`WasmStageRunner`] edda had,
//! [`XtaskWasmStageRunner`](crate::wasm::XtaskWasmStageRunner) — the HOST's
//! `wasm-bindgen` and HOST cargo. So a module the image compiled was wrapped by a
//! host tool.
//!
//! Lifted verbatim-in-substance from facett (`xtask/src/door.rs` + its stages module, deleted
//! there once the road lived here — facett 6127d496 —
//! measured on oden and dwarf 2026-09-29) when a second and third repo wanted it:
//! `nornir_pages::demo_wasm` (nornir, dwarves) builds its demo row through
//! [`build_row`], and facett's xtask uses the same three calls. One road, no copy.
//!
//! ## What the host contributes
//!
//! A container engine socket and a workdir — nothing else. [`Road::enter`] asks the
//! engine for the PINNED reference first and mints only when it is absent. A mint
//! writes the recipe's own tag (`<image>:recipe-<hash>`), never `<image>:live`: a
//! floating tag every session on a shared box sees, and moving it un-named the pin
//! other repos build in (2026-10-05).
//!
//! ## The bindgen CLI is built IN the image, offline, once per version
//!
//! The toolchain image carries no `wasm-bindgen`, and the run is `--network none`.
//! So the stage `cargo install --offline --locked wasm-bindgen-cli --version
//! <Cargo.lock's>` into the bound target dir and reuses it. The registry is bound
//! READ-ONLY and `cargo install` must extract sources it has never built (MEASURED
//! oden 2026-09-29: `Read-only file system` on `/cargo/registry/src/…`), so the install
//! runs with a scratch `CARGO_HOME` whose `index` and `cache` are symlinks onto the
//! read-only registry and whose `src` is writable; the scratch is removed afterwards.
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use crate::cargo_container::{
self as cc, CargoContainerRunner, CargoRunRequest, CargoToolchain, DraupnirCargoRunner, HostPaths,
};
use crate::variant::{BuildVariant, BuildVariantSet};
use crate::wasm::{WasmStageRun, WasmStageRunner};
/// **The build environment, resolved and entered ONCE** — the pinned toolchain,
/// the host paths it binds, and the engine.
pub struct Road {
/// The validated, pinned toolchain (after the `CARGO_BUILD_JOBS` cap).
pub tc: CargoToolchain,
/// The host target dir and registry the image binds.
pub paths: HostPaths,
runner: DraupnirCargoRunner,
}
/// **The declared toolchain, validated, with the one run-time cap a caller may
/// apply**: `CARGO_BUILD_JOBS` below the declared `cpus` lowers the container's CPU
/// bound (announced), never raises it. It changes scheduling, not the artifact.
///
/// Refuses BY NAME: no `[build.toolchain]`, and every `CargoToolchain::validate`
/// arm. There is no host fallback.
pub fn toolchain(set: &BuildVariantSet) -> Result<CargoToolchain> {
let mut tc = cc::toolchain_with_override(set.toolchain.clone())?.ok_or_else(|| {
anyhow!(
"the declaration has no `[build.toolchain]`, so there is no image to build in — \
and the container road does NOT fall back to host cargo"
)
})?;
tc.validate()?;
if let Some(jobs) = std::env::var("CARGO_BUILD_JOBS").ok().and_then(|j| j.trim().parse::<u32>().ok()) {
if jobs > 0 && jobs < tc.cpus {
eprintln!("[cargo-container] CARGO_BUILD_JOBS={jobs}: container bound {} → {jobs} cpus", tc.cpus);
tc.cpus = jobs;
}
}
Ok(tc)
}
impl Road {
/// **Enter the road**: validate the toolchain, ask the engine for the PINNED
/// reference; present → build in it and mint nothing; absent → mint from the
/// Containerfile embedded in this crate (reproducibly, under the recipe tag)
/// and require the pin is what the engine now holds. A pin the recipe does not
/// mint is refused by name.
pub fn enter(set: &BuildVariantSet, root: &Path) -> Result<Road> {
let tc = toolchain(set)?;
let paths = HostPaths::from_env(root);
std::fs::create_dir_all(&paths.target_dir)
.with_context(|| format!("create {}", paths.target_dir.display()))?;
let runner = DraupnirCargoRunner::new();
let pinned = tc.image_ref();
let t0 = std::time::Instant::now();
// Step 1 never writes a floating tag: present → nothing minted; absent →
// minted under the recipe's own content-addressed tag, and the pin must
// resolve afterwards (`cargo_container::ensure_declared_toolchain`).
let ctx = paths.target_dir.join(format!("{}-context", tc.image));
let minted = runner.ensure_declared_toolchain(&ctx, &tc).map_err(|e| {
anyhow!(
"step 1, the declared toolchain image {pinned}: {e:#}\n The build needs a \
container engine socket (rootless podman: $XDG_RUNTIME_DIR/podman/podman.sock) \
— the ONE thing the host is asked for. Nothing pulls {pinned}; every box mints \
it from the committed recipe."
)
})?;
eprintln!(
"[cargo-container] {} → building in {pinned} (rust {}, {} MiB, {} cpus, network {}, \
siblings: {})",
match &minted {
cc::ToolchainMint::Minted { tag } => {
format!("{tag} minted in {:.1} s", t0.elapsed().as_secs_f64())
}
cc::ToolchainMint::AlreadyPresent => {
"the pin is already in the engine, nothing minted".to_string()
}
},
tc.rust_version,
tc.mem_limit_mb,
tc.cpus,
if tc.network { "on" } else { "none" },
tc.siblings.join(", ")
);
Ok(Road { tc, paths, runner })
}
/// The engine the road runs on.
pub fn runner(&self) -> &DraupnirCargoRunner {
&self.runner
}
}
/// The refusal for a row cargo cannot build (container, package, iso, …), naming
/// it — `None` for a cargo kind.
#[must_use]
pub fn refusal(v: &BuildVariant) -> Option<String> {
(!v.kind.is_cargo()).then(|| {
format!(
"refused: '{}' is kind '{}', not a cargo build — the container road builds cargo rows only",
v.name,
v.kind.as_str()
)
})
}
/// **Build ONE cargo row IN THE CONTAINER**: the forge's argv
/// (`build_variant_in_container`), then the row's `[[build.wasm]]` stages when it
/// declares a cell — in the same image ([`ContainerStages`]). Returns the files
/// produced (the bundle's, when there is a cell). A row cargo cannot build
/// (container, package, iso, …) is refused BY NAME.
pub fn build_row(set: &BuildVariantSet, v: &BuildVariant, root: &Path, road: &Road) -> Result<Vec<PathBuf>> {
if let Some(r) = refusal(v) {
return Err(anyhow!(r));
}
eprintln!("── {} · {} · in {}", v.name, v.command_line(), road.tc.image_ref());
let t0 = std::time::Instant::now();
let out = cc::build_variant_in_container(
set,
&v.name,
root,
&road.paths.target_dir,
road.paths.registry_bind(),
&road.tc,
&road.runner,
)?;
if !out.success {
return Err(anyhow!(
"variant '{}': cargo did not succeed in {} ({})",
v.name,
road.tc.image_ref(),
v.command_line()
));
}
eprintln!(
" built in {:.1} s · provenance digest {} · toolchain {}",
t0.elapsed().as_secs_f64(),
out.attestation.digest_hex(),
out.attestation.record.toolchain.rustc
);
match set.wasm(&v.name) {
None => Ok(out.artifacts),
Some(cell) => {
// The raw module is where the CONTAINER wrote it — under the bound target dir.
let raw = crate::wasm::cargo_wasm_path(v, &road.paths.target_dir);
let stages = ContainerStages { tc: &road.tc, paths: &road.paths, runner: &road.runner, row: v };
let t1 = std::time::Instant::now();
let produced = crate::wasm::run_stages(cell, v, &raw, root, &stages)?;
eprintln!(" stages in {:.1} s · {}", t1.elapsed().as_secs_f64(), stages.describe());
Ok(produced)
}
}
}
/// **A declared wasm row, built and staged** — [`Road::enter`] + [`build_row`], and
/// the dist dir its `[[build.wasm]]` cell writes. Refused by name when the row does
/// not exist or declares no cell (no bindgen, no bundle).
pub fn build_wasm_row(set: &BuildVariantSet, variant: &str, root: &Path) -> Result<(Vec<PathBuf>, PathBuf)> {
let row = set.require(variant)?;
let cell = set
.wasm(&row.name)
.ok_or_else(|| anyhow!("'{}' declares no [[build.wasm]] cell — no bindgen, no bundle", row.name))?;
let dist = cell.out_dir(root);
let road = Road::enter(set, root)?;
let produced = build_row(set, row, root, &road)?;
Ok((produced, dist))
}
/// The row's feature selection as cargo flags — the same two fields `cargo_args()`
/// renders, in the same spelling, so the manifest example is compiled with the
/// features of the module it describes.
#[must_use]
pub fn row_feature_args(row: &BuildVariant) -> Vec<String> {
let mut a = Vec::new();
if row.no_default_features {
a.push("--no-default-features".to_string());
}
if !row.features.is_empty() {
a.push("--features".to_string());
a.push(row.features.join(","));
}
if row.all_features {
a.push("--all-features".to_string());
}
a
}
/// **The `[[build.wasm]]` stages, in the pinned image** — the second impl of
/// [`WasmStageRunner`], over the container runner. Owns no row knowledge: stage,
/// input, out dir and flags arrive typed from the cell; the wasm-bindgen version is
/// `Cargo.lock`'s.
pub struct ContainerStages<'a> {
/// The pinned toolchain.
pub tc: &'a CargoToolchain,
/// The bound host paths.
pub paths: &'a HostPaths,
/// The engine.
pub runner: &'a dyn CargoContainerRunner,
/// The row whose stages these are — its features reach the manifest example
/// (edda's `WasmStageRun::ComponentManifest` carries none; MEASURED oden
/// 2026-09-29: a feature-gated group got the default build's manifest).
pub row: &'a BuildVariant,
}
impl ContainerStages<'_> {
/// The container path a host path under the repo or the target dir lands at.
fn inside(&self, repo_root: &Path, p: &Path) -> Result<String> {
if let Ok(rel) = p.strip_prefix(&self.paths.target_dir) {
return Ok(format!("{}/{}", cc::TARGETDIR, rel.display()));
}
if let Ok(rel) = p.strip_prefix(repo_root) {
return Ok(format!("{}/{}", self.tc.container_repo_dir(repo_root), rel.display()));
}
Err(anyhow!(
"{} is neither under the repo nor under the bound target dir {} — the container \
cannot see it",
p.display(),
self.paths.target_dir.display()
))
}
/// The image's own toolchain, the bound target dir, the read-only registry, and
/// the cpu bound as cargo's job count.
fn prologue(&self) -> String {
let mut s = format!(
"export CARGO_HOME={}\nexport CARGO_TARGET_DIR={}\nexport CARGO_TERM_COLOR=never\n\
export CARGO_BUILD_JOBS={}\n",
cc::CARGO_HOME,
cc::TARGETDIR,
self.tc.cpus
);
if !self.tc.rust_version.is_empty() {
s.push_str(&format!("export RUSTUP_TOOLCHAIN={}\n", q(&self.tc.rust_version)));
}
s
}
fn run_script(&self, label: &str, script: String, repo_root: &Path) -> Result<()> {
let req = CargoRunRequest {
name: format!("{}-wasm-stage-{label}", self.row.name),
image: self.tc.image_ref(),
script,
binds: self.tc.binds(repo_root, &self.paths.target_dir, self.paths.registry_bind()),
timeout: std::time::Duration::from_secs(self.tc.timeout_secs),
network: self.tc.network,
mem_limit_mb: self.tc.mem_limit_mb,
cpus: self.tc.cpus,
};
let out = self.runner.run(&req)?;
if out.exit_code != Some(0) {
let all: Vec<&str> = out.stderr.iter().chain(out.stdout.iter()).map(String::as_str).collect();
let tail = &all[all.len().saturating_sub(30)..];
return Err(anyhow!(
"wasm stage `{label}` exited {:?} in {}:\n{}",
out.exit_code,
self.tc.image_ref(),
tail.join("\n")
));
}
Ok(())
}
}
/// POSIX single-quoting: every value reaching the script is typed, and quoted anyway.
fn q(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
impl WasmStageRunner for ContainerStages<'_> {
fn describe(&self) -> String {
format!(
"wasm-bindgen (Cargo.lock's version, built offline in the image) + the manifest \
example, IN {}",
self.tc.image_ref()
)
}
fn run(&self, run: &WasmStageRun, repo_root: &Path) -> Result<()> {
match run {
WasmStageRun::Bindgen { input, out_dir, target, typescript } => {
let version = nornir_xtask_support::locked_wasm_bindgen_version(repo_root)
.map_err(|e| anyhow!("the wasm-bindgen version Cargo.lock pins: {e}"))?;
let tool = format!("{}/wasm-bindgen-cli-{version}", cc::TARGETDIR);
let scratch = format!("{tool}-home");
let registry = format!("{}/registry", cc::CARGO_HOME);
let mut bindgen = format!(
"{}/bin/wasm-bindgen {} --out-dir {} --target {}",
q(&tool),
q(&self.inside(repo_root, input)?),
q(&self.inside(repo_root, out_dir)?),
q(target)
);
if !typescript {
bindgen.push_str(" --no-typescript");
}
let script = format!(
"{prologue}\
if [ ! -x {tool}/bin/wasm-bindgen ]; then\n\
rm -rf {scratch}; mkdir -p {scratch}/registry/src\n\
ln -s {registry}/index {scratch}/registry/index\n\
ln -s {registry}/cache {scratch}/registry/cache\n\
CARGO_HOME={scratch} CARGO_TARGET_DIR={scratch}/target cargo install --offline \
--locked wasm-bindgen-cli --version {v} --root {tool}\n\
rm -rf {scratch}\n\
fi\n\
{tool}/bin/wasm-bindgen --version\n\
{bindgen}\n",
prologue = self.prologue(),
tool = q(&tool),
scratch = q(&scratch),
registry = q(®istry),
v = q(&version),
);
self.run_script("bindgen", script, repo_root)
}
WasmStageRun::ComponentManifest { package, example, out_file } => {
let out = self.inside(repo_root, out_file)?;
let feats: String = row_feature_args(self.row).iter().map(|a| format!(" {}", q(a))).collect();
let script = format!(
"{prologue}export RUSTFLAGS={flags}\ncd {work}\n\
cargo run --locked {offline}--quiet --example {example} -p {package}{feats} > {out}.part\n\
mv {out}.part {out}\n",
prologue = self.prologue(),
flags = q(&self.tc.container_rustflags()),
work = q(&self.tc.container_repo_dir(repo_root)),
offline = if self.tc.network { "" } else { "--offline " },
example = q(example),
package = q(package),
out = q(&out),
);
self.run_script("component-manifest", script, repo_root)?;
// An empty capture is a loader that enumerates no components while
// every check passes.
match std::fs::metadata(out_file) {
Ok(m) if m.len() > 0 => Ok(()),
_ => Err(anyhow!(
"the `{example}` example of `{package}` exited 0 in {} and {} is empty or \
absent — there is no manifest to ship",
self.tc.image_ref(),
out_file.display()
)),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_args_render_the_rows_two_fields() {
let mut v = BuildVariant { name: "r".into(), ..Default::default() };
assert!(row_feature_args(&v).is_empty());
v.no_default_features = true;
v.features = vec!["a".into(), "b".into()];
assert_eq!(row_feature_args(&v), ["--no-default-features", "--features", "a,b"]);
}
#[test]
fn a_non_cargo_row_is_refused_by_name_before_any_engine_is_asked() {
use crate::variant::ArtifactKind;
let v = BuildVariant { name: "img".into(), kind: ArtifactKind::Container, ..Default::default() };
let set = BuildVariantSet::default();
// No engine, no toolchain: the refusal must come first.
assert!(refusal(&v).unwrap().contains("'img' is kind 'container'"));
assert!(refusal(&BuildVariant { name: "x".into(), ..Default::default() }).is_none());
assert!(build_wasm_row(&set, "absent", Path::new(".")).is_err());
}}