Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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);
    }
}