crates/nornir-build-thing/examples/name_collision_recovery.rs
//! **The crash-recovery proof, on a live engine, across two processes.**
//!
//! An interrupted build leaves its container behind. Its name is deterministic
//! (`draupnir-<job>`), so the NEXT run of that job wants the same name — and
//! before draupnir 0.1.14 the box was wedged from that moment on: every ISO
//! compose died 381 s in with the daemon's `the container name "…" is already in
//! use by …`, four layers below the user, and only a human with `podman rm -f`
//! could clear it (measured, gunnar appliance bring-up 2026-09-20).
//!
//! It runs in two modes, deliberately in two SEPARATE processes, because that is
//! the whole point: the run that leaves the wreckage is the run that was killed,
//! and a killed process runs no destructor. Recovery has to belong to the next
//! start.
//!
//! ```text
//! cargo run -p nornir-build-thing --features cargo-container \
//! --example name_collision_recovery -- wreck <image> [job] # then kill nothing, just leave it
//! cargo run ... --example name_collision_recovery -- recover <image> [job]
//! ```
//!
//! `wreck` leaves a stopped container called `draupnir-<job>` behind.
//! `recover` drives the REAL [`DraupnirCargoRunner`] at that same job twice:
//! over the stopped leftover (must be reaped, announced, and the build must run)
//! and over a LIVE container of the same name (must be refused by name, and the
//! live one must still be there afterwards — it is somebody else's build).
use draupnir::container::{ContainerBoot, ContainerControl, ContainerState};
use draupnir::{Boot, BootSpec};
use nornir_build_thing::cargo_container::{
CargoContainerRunner, CargoRunRequest, DraupnirCargoRunner,
};
use std::time::Duration;
fn request(name: &str, image: &str) -> CargoRunRequest {
CargoRunRequest {
name: name.to_string(),
image: image.to_string(),
script: "echo THE-BUILD-RAN".to_string(),
binds: Vec::new(),
timeout: Duration::from_secs(300),
network: false,
mem_limit_mb: 512,
cpus: 1,
}
}
fn held(boot: &ContainerBoot, job: &str) -> Vec<(String, ContainerState)> {
boot.list_containers(&format!("draupnir-{job}"))
.expect("the engine can be asked who holds a name")
}
fn main() -> anyhow::Result<()> {
let mode = std::env::args().nth(1).expect("mode: wreck | recover");
let image = std::env::args().nth(2).expect("an image reference to run in");
let job = std::env::args()
.nth(3)
.unwrap_or_else(|| "name-collision-proof".to_string());
let boot = ContainerBoot::new();
if mode == "wreck" {
// Exactly what a killed ladder run leaves. The container is BOOTED and
// then this process dies where it stands — no drain, no `stop`, no
// destructor, which is the whole reason the cleanup at the end of
// `drive_to_completion` cannot be the answer: the run that leaves the
// wreckage is the run that never reaches its own last line. The container
// finishes its command by itself a moment later and stays there, exited,
// holding the name for ever.
let spec = BootSpec::container(&job, &image)
.with_cmd(["/bin/sh", "-eu", "-c", "echo wreckage"]);
let machine = boot.boot(&spec)?;
println!("[wreck] booted {} — now dying without cleaning up", machine.id);
std::io::Write::flush(&mut std::io::stdout()).ok();
// 137 is what a `pkill -9`ed build reports. Nothing below this line runs.
std::process::exit(137);
}
assert_eq!(mode, "recover", "mode is `wreck` or `recover`");
println!("[recover] before: {:?}", held(&boot, &job));
let runner = DraupnirCargoRunner::new();
let out = runner.run(&request(&job, &image))?;
println!(
"[proof 1] the build RAN over the leftover: exit={:?} stdout={:?}",
out.exit_code, out.stdout
);
assert_eq!(out.exit_code, Some(0), "the next run must recover by itself");
// --- and the other half: a LIVE container holding the name. -----------
let live_job = format!("{job}-live");
let live = BootSpec::container(&live_job, &image)
.with_cmd(["/bin/sh", "-eu", "-c", "sleep 600"]);
let live_machine = boot.boot(&live)?;
println!("[stage] a LIVE container holds draupnir-{live_job}");
let refused = runner
.run(&request(&live_job, &image))
.expect_err("a live container is a concurrent build, not wreckage");
println!("[proof 2] {refused:#}");
let after = held(&boot, &live_job);
println!("[proof 2] and it is STILL running afterwards: {after:?}");
assert!(
matches!(after.first().map(|(_, s)| s.clone()), Some(ContainerState::Running)),
"a refusal must never kill the other lane's build"
);
// Clean up only what this example started.
boot.stop(&live_machine);
println!("[done] both proofs held");
Ok(())
}