xtask/src/payload.rs
//! `cargo xtask payload` — **what a medium would boot, without the medium.**
//!
//! The estate-box gate (private-gunnar-ops `cargo xtask estate-box`) runs the
//! estate's three machines as processes BEFORE any ISO is composed. It must
//! run the SAME program the medium carries, started the SAME way. This verb
//! answers both, from the same code the compose takes:
//!
//! 1. **the program**: the installer cell's PAYLOAD row is built by the one Build Thing door
//! (`dwarves buildthing <gunnar> --variant <payload>`: the pinned
//! toolchain container, musl, the row's own features). The compose builds
//! that same row through the same door, so the binary here is the binary
//! there — the gate records its sha256;
//! 2. **the start**: the cell is resolved exactly as edda's
//! `suite_cli::run_installer_selected` resolves it for the composer (the
//! `--opt` line split by the settings tree, the group choices, the
//! selection with the `--param` values) and its `effective_boot_params()`
//! are the kernel command line korp-start reads; every `staged_file`
//! parameter is written where korp-installer lays it on the box
//! (`gunnar_recipe::reachability::staged_runtime_path`: `/data/<rel>`).
//!
//! Output is a directory (xtask stays serde-free): `cmdline` (one line),
//! `binary` (the absolute path), `sha256`, `payload-variant`, and
//! `staged/<runtime path without the leading />` for each staged file.
//!
//! What this verb deliberately does NOT do: compose, sign a medium, or build a
//! kernel. korp-start's own work on the box (mounting /data, sealing the
//! identity, fexecve from the verified archive) is the KVM mock's to prove.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use gunnar_recipe::nornir_build_thing as nbt;
/// The parsed passthrough: the same flags `iso-installer` takes, plus `--out`.
#[derive(Debug, Default)]
struct Ask {
variant: String,
opts: Vec<String>,
params: Vec<String>,
out: Option<PathBuf>,
/// `--no-build`: resolve the start only (the unit tests, and a gate that
/// already holds the binary).
no_build: bool,
}
fn parse(args: &[String]) -> Result<Ask, String> {
let mut a = Ask::default();
let mut i = 0;
while i < args.len() {
let need = |i: usize, what: &str| {
args.get(i + 1)
.cloned()
.ok_or_else(|| format!("{what} needs a value"))
};
match args[i].as_str() {
"--variant" => a.variant = need(i, "--variant")?,
"--opt" => a.opts.push(need(i, "--opt")?),
"--param" => a.params.push(need(i, "--param")?),
"--out" => a.out = Some(PathBuf::from(need(i, "--out")?)),
"--no-build" => {
a.no_build = true;
i += 1;
continue;
}
other => return Err(format!("unknown flag `{other}` (payload takes --variant, --opt, --param, --out, --no-build)")),
}
i += 2;
}
if a.variant.is_empty() {
return Err("--variant <installer cell> is required".into());
}
Ok(a)
}
/// What the medium would boot.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Start {
/// The installer cell's payload row (`gunnar-server-metered`, …).
pub payload_variant: String,
/// The package's binary name in that row.
pub bin: String,
/// The kernel command line korp-start reads.
pub cmdline: String,
/// `(runtime path, contents)` for each staged parameter.
pub staged: Vec<(String, String)>,
}
fn say(e: impl std::fmt::Display) -> String {
format!("{e:#}")
}
/// **Resolve an installer cell with its site options and parameters** — the
/// composer's own resolution (edda `suite_cli::run_installer_selected`, steps
/// for steps), stopping where the composer would start writing an ISO.
pub fn resolve(
gunnar_root: &Path,
cell_name: &str,
opts: &[String],
params: &[String],
) -> Result<Start, String> {
let set = nbt::variant::BuildVariantSet::discover(gunnar_root)
.map_err(say)?
.ok_or("gunnar declares no build space")?;
let mut cell = set
.installers
.iter()
.find(|i| i.name == cell_name)
.cloned()
.ok_or_else(|| format!("no [[build.installer]] cell named '{cell_name}'"))?;
let supplied = nbt::params::parse_cli_params(params).map_err(say)?;
let tree = nbt::tree::SettingsTree::of_installer(&cell, None);
let split = tree
.split_opts(opts)
.map_err(|x| format!("installer '{cell_name}': {x:#}"))?;
cell.apply_group_choices(&split.choices).map_err(say)?;
cell.apply_cli_selection_with(&split.features, &supplied, &nbt::params::NoFixtures)
.map_err(say)?;
let mut staged = Vec::new();
for rec in &cell.applied_params {
let Some(spec) = cell.params.iter().find(|p| p.name == rec.name) else {
continue;
};
if spec.staged_file.trim().is_empty() {
continue;
}
let at = gunnar_recipe::reachability::staged_runtime_path(
nbt::variant::ArtifactKind::Installer,
&spec.staged_file,
)
.ok_or_else(|| {
format!(
"parameter '{}': no runtime path for staged file '{}'",
rec.name, spec.staged_file
)
})?;
staged.push((at, rec.value.clone()));
}
let declared = set
.variants
.iter()
.find(|v| v.name == cell.variant)
.ok_or_else(|| {
format!(
"installer '{cell_name}' names payload '{}', which is not a declared row",
cell.variant
)
})?;
// The composer pushes the options' features INTO the payload row it
// builds (dwarves assemble_cell::resolve_installer_options →
// ResolvedOptions::apply_to_set). The Build Thing door builds declared
// rows only, so the row built is the declared one carrying exactly those
// features (DEV-NARRATE: `gunnar-server-metered` + dev-narrate is the
// declared `gunnar-server-metered-narrate`), or a refusal by name.
let resolved = nbt::options::resolve_options(&cell.options, &cell.params, &split.features)
.map_err(|x| format!("installer '{cell_name}': {x:#}"))?;
let want: std::collections::BTreeSet<String> =
resolved.app_features(declared).into_iter().collect();
// The row is found by package, bin, target and FEATURES — never by profile: the narrate
// rows are `dev-media` builds of the same binary (2026-10-12), and the profile equality
// refused every dev-narrate estate's gate `[payload-options-no-row]` (test2, 2026-10-13).
let same = |v: &nbt::variant::BuildVariant| {
v.package == declared.package && v.bin == declared.bin && v.target == declared.target
};
let row = if want == declared.features.iter().cloned().collect() {
declared
} else {
set.variants
.iter()
.find(|v| same(v) && v.features.iter().cloned().collect::<std::collections::BTreeSet<String>>() == want)
.ok_or_else(|| format!("REFUSED [payload-options-no-row] installer '{cell_name}': its options ask `{}` with features {want:?}, and no declared row of that package carries exactly them — the Build Thing door builds declared rows only", declared.name))?
};
let bin = row
.bin
.clone()
.or_else(|| row.package.clone())
.ok_or_else(|| format!("payload row `{}` names no bin and no package", row.name))?;
Ok(Start {
payload_variant: row.name.clone(),
bin,
cmdline: cell.effective_boot_params().join(" "),
staged,
})
}
/// **Build the payload row through the Build Thing door**, the compose's own
/// (`isoinstaller::run` calls the same `dwarves buildthing`); answers the
/// binary it left.
fn build(gunnar_root: &Path, payload_variant: &str, bin: &str) -> Result<PathBuf, String> {
let dwarves = crate::isoinstaller::dwarves_root(gunnar_root)?;
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let mut cmd = Command::new(&cargo);
cmd.current_dir(&dwarves);
cmd.env_remove("CARGO_MANIFEST_DIR");
// The dwarves tool's own build goes where the compose's goes; the payload's
// target is the container's mount (<gunnar>/target), whatever this says.
cmd.args([
"run",
"-p",
"dwarves",
"--features",
"appliance-iso",
"--bin",
"dwarves",
"--",
"buildthing",
])
.arg(gunnar_root)
.args(["--variant", payload_variant]);
let out = cmd.output().map_err(|e| format!("spawn {cargo}: {e}"))?;
let said =
String::from_utf8_lossy(&out.stdout).to_string() + &String::from_utf8_lossy(&out.stderr);
eprint!("{said}");
if !out.status.success() {
return Err(format!("REFUSED [payload-not-built] the Build Thing did not build `{payload_variant}` (exit {})", out.status));
}
artifact_named(&said, bin).ok_or_else(|| format!("REFUSED [payload-not-built] the Build Thing reported green and named no `{bin}` artifact"))
}
/// The `artifact <path>` line the Build Thing printed for `bin`. Pure.
pub fn artifact_named(said: &str, bin: &str) -> Option<PathBuf> {
said.lines()
.filter_map(|l| l.trim().strip_prefix("artifact "))
.map(|p| PathBuf::from(p.trim()))
.find(|p| p.file_name().and_then(|n| n.to_str()) == Some(bin))
}
fn sha256_of(p: &Path) -> Result<String, String> {
let o = Command::new("sha256sum")
.arg(p)
.output()
.map_err(|e| format!("sha256sum: {e}"))?;
let s = String::from_utf8_lossy(&o.stdout);
s.split_whitespace()
.next()
.filter(|h| h.len() == 64)
.map(str::to_string)
.ok_or_else(|| format!("sha256sum {}: {s}", p.display()))
}
fn write(dir: &Path, start: &Start, binary: Option<(&Path, &str)>) -> Result<(), String> {
let w = |p: PathBuf, s: &str| -> Result<(), String> {
if let Some(d) = p.parent() {
std::fs::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?;
}
std::fs::write(&p, s).map_err(|e| format!("{}: {e}", p.display()))
};
let _ = std::fs::remove_dir_all(dir.join("staged"));
w(dir.join("cmdline"), &format!("{}\n", start.cmdline))?;
w(
dir.join("payload-variant"),
&format!("{}\n", start.payload_variant),
)?;
for (at, contents) in &start.staged {
w(
dir.join("staged").join(at.trim_start_matches('/')),
contents,
)?;
}
if let Some((bin, sha)) = binary {
w(dir.join("binary"), &format!("{}\n", bin.display()))?;
w(dir.join("sha256"), &format!("{sha}\n"))?;
}
Ok(())
}
pub fn run(gunnar_root: &Path, args: &[String]) -> Result<bool, String> {
let ask = parse(args)?;
if let Some(why) = crate::isoinstaller::worktree_refusal(gunnar_root) {
return Err(format!("REFUSED [iso-installer-worktree] {why}"));
}
let start = resolve(gunnar_root, &ask.variant, &ask.opts, &ask.params)?;
let mut built = None;
if !ask.no_build {
let bin = build(gunnar_root, &start.payload_variant, &start.bin)?;
let sha = sha256_of(&bin)?;
eprintln!(
"payload: {} ({}) {} sha256 {sha}",
ask.variant,
start.payload_variant,
bin.display()
);
built = Some((bin, sha));
}
match &ask.out {
Some(dir) => write(
dir,
&start,
built.as_ref().map(|(b, s)| (b.as_path(), s.as_str())),
)?,
None => println!("{}", start.cmdline),
}
Ok(true)
}
/// `--param` values as the kit passes them, keyed (a test helper and a reader
/// for the gate's own record).
pub fn params_map(params: &[String]) -> BTreeMap<String, String> {
params
.iter()
.filter_map(|p| p.split_once('='))
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf()
}
#[test]
fn the_metered_server_starts_as_its_medium_says() {
let seed = std::env::temp_dir().join(format!("payload-seed-{}.toml", std::process::id()));
std::fs::write(&seed, "estate = \"x\"\n").unwrap();
let s = resolve(
&root(),
"gunnar-installer-metered",
&[],
&[format!("pairing_seed=@{}", seed.display())],
)
.unwrap();
assert_eq!(s.payload_variant, "gunnar-server-metered");
assert_eq!(s.bin, "gunnar-server");
for t in [
"korp.env.GUNNAR_DATA_ROOT=/data",
"korp.env.GUNNAR_NET=eth0:dhcp,eth1:dhcp",
"korp.args=serve,--grpc-bind,utility:50051",
"korp.env.GUNNAR_APPLIANCE=1",
] {
assert!(
s.cmdline.split_whitespace().any(|x| x == t),
"{t} not in {}",
s.cmdline
);
}
assert!(
s.cmdline.contains("korp.env.GUNNAR_MONETIZE_PUBKEY="),
"{}",
s.cmdline
);
assert_eq!(
s.staged,
vec![(
"/data/pairing/seed.toml".to_string(),
"estate = \"x\"\n".to_string()
)]
);
let _ = std::fs::remove_file(seed);
}
#[test]
fn the_metered_front_starts_as_its_medium_says() {
let s = resolve(&root(), "gunnar-front-installer-metered", &[], &[]).unwrap();
assert_eq!(
(s.payload_variant.as_str(), s.bin.as_str()),
("gunnar-front-metered", "gunnar-front")
);
assert!(
s.cmdline.contains("korp.entry=gunnar-front"),
"{}",
s.cmdline
);
assert!(!s.cmdline.contains("korp.args="), "{}", s.cmdline);
}
/// ★ estate-box3 (DEV-NARRATE parity): the composer pushes an installer
/// option's feature INTO the payload row it builds (dwarves
/// assemble_cell::resolve_installer_options → ResolvedOptions::apply_to_set);
/// this verb built the cell's row as declared, so `--opt dev-narrate=on`
/// changed nothing and the gate booted a box with no narrate sender. The
/// row built is the DECLARED row carrying exactly the resolved features.
#[test]
fn an_option_that_turns_a_feature_on_builds_the_row_that_carries_it() {
let on = resolve(
&root(),
"gunnar-front-installer-metered",
&["dev-narrate=on".to_string()],
&[],
)
.unwrap();
assert_eq!(on.payload_variant, "gunnar-front-metered-narrate");
assert_eq!(on.bin, "gunnar-front");
let off = resolve(
&root(),
"gunnar-front-installer-metered",
&["dev-narrate=off".to_string()],
&[],
)
.unwrap();
assert_eq!(off.payload_variant, "gunnar-front-metered");
let seed = std::env::temp_dir().join(format!("payload-seed-n-{}.toml", std::process::id()));
std::fs::write(&seed, "estate = \"x\"\n").unwrap();
let server = resolve(
&root(),
"gunnar-installer-metered",
&["dev-narrate=on".to_string()],
&[format!("pairing_seed=@{}", seed.display())],
)
.unwrap();
assert_eq!(server.payload_variant, "gunnar-server-metered-narrate");
let _ = std::fs::remove_file(seed);
}
/// **Stinky `payload-schema-skew`** (estate-box3, 2026-09-30): the payload
/// door is the gate's way to resolve ANY suite member's installer cell, and
/// it refused monetize's real `.nornir/build-variants.toml` — "this recipe
/// is schema 5; this nornir-build-thing (0.1.20) understands up to schema 4"
/// — because gunnar pinned an older reader than edda release-3 and dwarves.
/// Monetize's file is read as it sits beside gunnar (the sibling layout the
/// kit clones), not a copy that could lag it.
#[test]
fn another_suite_members_schema_5_cell_resolves_through_the_payload_door() {
let monetize = root().parent().unwrap().join("monetize");
let declared = std::fs::read_to_string(monetize.join(".nornir/build-variants.toml"))
.unwrap_or_else(|e| {
panic!(
"monetize must sit beside gunnar ({}): {e}",
monetize.display()
)
});
let schema: u32 = declared
.lines()
.find_map(|l| {
l.trim()
.strip_prefix("schema = ")
.map(|v| v.trim().parse().unwrap())
})
.expect("monetize's declaration carries a schema header");
assert!(
schema >= 5,
"monetize's declaration is schema {schema}; this test is for the schema-5 file"
);
assert!(
nbt::schema::RECIPE_SCHEMA >= schema,
"the payload door's nornir-build-thing understands up to schema {}; monetize declares schema {schema}",
nbt::schema::RECIPE_SCHEMA
);
let s = resolve(&monetize, "monetize-admin-installer", &[], &[]).unwrap();
assert_eq!(
(s.payload_variant.as_str(), s.bin.as_str()),
("monetize-admin", "monetize-admin")
);
assert!(
s.cmdline
.split_whitespace()
.any(|x| x == "korp.entry=monetize-admin"),
"{}",
s.cmdline
);
}
#[test]
fn the_artifact_line_names_the_binary() {
let said = " artifact /w/target/x86_64-unknown-linux-musl/release/gunnar-server\n artifact /w/x.p7s\n";
assert_eq!(
artifact_named(said, "gunnar-server"),
Some(PathBuf::from(
"/w/target/x86_64-unknown-linux-musl/release/gunnar-server"
))
);
assert_eq!(artifact_named(said, "gunnar-front"), None);
}
}