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

nordisk/gunnar

installtest/src/subject.rs

//! **What is being installed** — the one question this crate never asked.
//!
//! Every arm here installs a `gunnar` binary and then makes statements about
//! what "gunnar" does. Until 2026-08-05 nothing established *which* gunnar that
//! was. [`crate::container::locate_binary`] took the first file it found on a
//! candidate list ending in a hard-coded scratch path, printed its age, and
//! carried on. MEASURED 2026-08-04: it ran one **1998 minutes old** — 33 hours,
//! built in another worktree — and reported a failure that was a
//! true statement about that binary and said nothing whatever about this tree.
//! That is exactly why `xtask ci` refused to run this crate.
//!
//! A [`Subject`] closes it in the only way that survives being wrong:
//!
//! 1. **It builds the binary itself** unless told otherwise, so the default
//!    answer to "which gunnar" is "the one this checkout describes".
//! 2. **It reads the binary's own account of itself.** `gunnar --version` has
//!    carried the git description and the build date since `008d74b`, so the
//!    binary can be asked rather than dated: [`Subject::prepare`] refuses one
//!    whose stamp does not name this checkout's `HEAD`.
//! 3. **It hands that string on**, and every install target re-reads it *after
//!    installing* ([`Subject::assert_installed`]). A container or a guest that
//!    reports a different version is running something else, and every number
//!    and every assertion taken from it is about a build nobody named.
//!
//! # Why `server,systemd` and not the appliance's `server`
//!
//! Both install arms drive gunnar's `service` verbs — `service unit`,
//! `service install`, `service start`, `service status` — and those are behind
//! `--features systemd` (`gunnar-cli/Cargo.toml:105`, and `systemd` implies
//! `server`). Built `--no-default-features --features server`, the binary answers
//!
//! ```text
//! error: unrecognized subcommand 'service'
//! ```
//!
//! **That is the whole of why `install_container` and `install_kvm` were red at
//! `v1.0`**, and neither arm said so: the container arm's systemd probe read the
//! failure through a log stream that had already raced (see
//! [`crate::container::systemd_install_refuses_without_a_bus`]) and blamed a
//! half install; the KVM arm just failed on the first `service` command with the
//! remote exit status. One missing feature, two arms, two misleading messages.
//!
//! The appliance itself is still built `--features server` and links no
//! Skidbladnir — `xtask ci`'s `appliance-build` step and the `gunnar-server`
//! build variant are unchanged. This crate's subject is a **host install**, and
//! a host install onto a machine that runs systemd is the thing `service
//! install` is for. Saying so in one place is cheaper than two arms discovering
//! it separately.

use std::path::{Path, PathBuf};
use std::process::Command;

/// Where every worktree's subject binaries live, on md1. One directory **per
/// worktree** underneath it; see [`target_dir_for`].
pub const TARGET_ROOT: &str = "~/scratch/cargo/gunnar-install";

/// The default target directory for the binary this crate builds — **derived
/// from the worktree this run is in**, not a constant.
///
/// Until 2026-08-05 this was the bare constant `TARGET_ROOT`, shared by every
/// checkout on the box, and that is a second way to test somebody else's build on
/// top of the one the module header describes. MEASURED 2026-08-05: a worktree
/// found a `gunnar` there stamped `v1.1-2-g13aa979-modified`, left by another
/// worktree. Its own change touched nothing under `gunnar-cli`, so nothing in
/// cargo's fingerprint had moved, the uplifted binary in `release/` was taken
/// as fresh — **including `build.rs`'s baked `git describe`** — and
/// [`Subject::prepare`] refused it for not naming this checkout's `HEAD`.
///
/// The guard was right and the path was wrong. The version assertion is not
/// weakened to make that red go away; the directory stops being shared.
pub fn default_target_dir() -> PathBuf {
    target_dir_for(&crate::workspace_root())
}

/// The lane-relative form, taking `root` explicitly.
///
/// Explicitly, and not off `workspace_root()` alone, so the property that
/// matters can actually be asserted: **two different worktrees must get two
/// different directories.** With a constant those two calls are equal, which is
/// exactly the bug, and no test that only ever passes one root can see it.
///
/// The hash is what carries the guarantee — worktree basenames collide
/// constantly here (`.../scratch/wt/redci/gunnar` and `~/git/gunnar`
/// are both `gunnar`) — and the basename is carried alongside it only so a
/// human reading `ls` can tell whose directory is whose.
///
/// The derivation itself moved to [`crate::lane_dir`] on 2026-08-05, because
/// `xtask`'s `install-test` task needs exactly the same one for the directory it
/// builds the **harness** in and cannot depend on this crate. One writer, two
/// callers, one implementation; this function is now the `TARGET_ROOT` half of it.
pub fn target_dir_for(root: &Path) -> PathBuf {
    crate::lane_dir::lane_dir(TARGET_ROOT, root)
}

/// Set to `1` to skip the build and take whatever is already at the located
/// path. The provenance checks below still run: this suppresses the *build*,
/// never the *identity*.
pub const NO_BUILD: &str = "GUNNAR_INSTALLTEST_NO_BUILD";

/// The gunnar under test, and its own account of itself.
#[derive(Clone, Debug)]
pub struct Subject {
    /// The binary on disk.
    pub path: PathBuf,
    /// The whole first line of `gunnar --version`, e.g.
    /// `gunnar 0.1.0 (v1.0-4-g008d74b, built 2026-08-05T06:26:30Z)`.
    pub version: String,
    /// The abbreviated `HEAD` of the checkout this subject must correspond to.
    pub head: String,
}

impl Subject {
    /// Produce the subject: build it, then make it identify itself.
    ///
    /// `explicit` (or `GUNNAR_INSTALLTEST_BINARY` / `GUNNAR_BENCH_BINARY`) names
    /// a binary to use instead of building one. It is **not** a way past the
    /// identity checks — a named binary is stamped like any other, and one that
    /// does not name this `HEAD` is refused with both strings in the message.
    pub fn prepare(explicit: Option<&Path>) -> Result<Subject, String> {
        let root = crate::workspace_root();
        // An unanswerable question is not a yes (the same rule
        // `locate_binary_for_measurement` applies to a missing source mtime): if
        // this checkout cannot say what commit it is at, nothing below can say
        // whether the binary matches it.
        let head = git_head(&root)?;

        let named = explicit
            .map(Path::to_path_buf)
            .or_else(|| {
                ["GUNNAR_INSTALLTEST_BINARY", "GUNNAR_BENCH_BINARY"]
                    .iter()
                    .find_map(|k| std::env::var(k).ok().map(PathBuf::from))
            })
            .filter(|p| p.is_file());

        let path = match named {
            Some(p) => p,
            None => build(&root)?,
        };

        let version = version_of(&path)?;
        // The stamp carries `git describe --tags --always --dirty=-modified`, so
        // it reads `v1.0-4-g008d74b` or `v1.0-4-g008d74b-modified`. The commit
        // is what is asserted, not the whole description: an ordinary
        // `cargo build` in this tree rewrites `Cargo.lock`, which makes the
        // description dirty without changing which commit the code came from.
        // The working-tree question is a different one and has its own guard,
        // `container::binary_staleness_secs`.
        if !version.contains(&head) {
            return Err(format!(
                "the binary at {} says it is `{version}`, which does not name this checkout's \
                 HEAD ({head}). It is a different gunnar from the one this run is about to \
                 make statements about. Drop --binary and let this crate build one, or run \
                 from the checkout that binary came from.",
                path.display()
            ));
        }

        // The `service` verbs are the whole install path for both arms. Asked
        // once, here, with the answer that names the feature — rather than
        // twice, in two arms, as `unrecognized subcommand 'service'` three
        // layers down a container log.
        let help = run_ok(Command::new(&path).arg("--help"), "gunnar --help")?;
        if !help.contains("service") {
            return Err(format!(
                "{} has no `service` verb, so it was built without `--features systemd` and \
                 neither install arm can install anything with it. This is the exact state \
                 both arms were red in at v1.0. Build it with \
                 `--no-default-features --features server,systemd`.",
                path.display()
            ));
        }

        eprintln!("installtest: subject {} — {version}", path.display());
        Ok(Subject {
            path,
            version,
            head,
        })
    }

    /// **The assertion an install target owes back.** `reported` is what
    /// `gunnar --version` printed *inside* the container, the guest, or
    /// wherever it was installed.
    ///
    /// Equality, not a substring of the word "gunnar". The weaker check was what
    /// [`crate::kvm::install`] did, and it passes for any gunnar ever built —
    /// including one a previous run left in the image cache, which is precisely
    /// the confusion this whole module exists to end.
    pub fn assert_installed(&self, reported: &str, target: &str) -> Result<(), String> {
        let got = reported.trim();
        if got == self.version {
            return Ok(());
        }
        Err(format!(
            "the gunnar installed {target} reports `{got}`, and the subject this run built and \
             pushed is `{}`. They are different builds, so nothing measured against that \
             install describes this tree.",
            self.version
        ))
    }
}

/// `git describe`'s commit half for this checkout, abbreviated the same way
/// `crates/gunnar-cli/build.rs` abbreviates it.
fn git_head(root: &Path) -> Result<String, String> {
    let out = Command::new("git")
        .current_dir(root)
        .args(["rev-parse", "--short=7", "HEAD"])
        .output()
        .map_err(|e| format!("git rev-parse in {}: {e}", root.display()))?;
    if !out.status.success() {
        return Err(format!(
            "`git rev-parse --short=7 HEAD` failed in {} ({}): {}. Without it nothing can say \
             whether the binary under test is this tree's.",
            root.display(),
            out.status,
            String::from_utf8_lossy(&out.stderr).trim()
        ));
    }
    let head = String::from_utf8_lossy(&out.stdout).trim().to_string();
    if head.is_empty() {
        return Err("`git rev-parse` printed nothing, so this checkout named no commit".into());
    }
    Ok(head)
}

/// The `[[bin]]` this crate installs: `gunnar-cli` built `--no-default-features
/// --features server,systemd`, the one shape with an init system under it. It was the
/// `gunnar-service` bin until GUN-PRODUCTS (2026-10-30): gunnar-cli is ONE program now,
/// and this crate's own target directory keeps the systemd build apart from the product's.
pub const SUBJECT_BIN: &str = "gunnar-cli";

/// Build the subject, streaming cargo's own output through.
fn build(root: &Path) -> Result<PathBuf, String> {
    let target_dir = std::env::var("GUNNAR_INSTALLTEST_TARGET")
        .map(PathBuf::from)
        .unwrap_or_else(|_| target_dir_for(root));
    let target_dir = target_dir.display().to_string();
    // `release/gunnar-cli` (`release/gunnar-service` until GUN-PRODUCTS, 2026-10-30).
    //
    // `gunnar-cli` declares five `[[bin]]` targets over one `src/main.rs` — one
    // per atom in `.nornir/build-variants.toml` — because three atoms sharing
    // `bin = "gunnar"` meant three builds fighting over one output path. The
    // argv below is `--features server,systemd`, and the ONLY bin whose
    // `required-features` that satisfies is `gunnar-service`, so that is the
    // file cargo writes. `--bin` is named explicitly rather than inferred, so
    // this cannot drift from the path it then asserts on.
    let binary = PathBuf::from(&target_dir).join(format!("release/{SUBJECT_BIN}"));

    if std::env::var(NO_BUILD).as_deref() == Ok("1") {
        if !binary.is_file() {
            return Err(format!(
                "{NO_BUILD}=1 and there is no binary at {}",
                binary.display()
            ));
        }
        return Ok(binary);
    }

    let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
    eprintln!(
        "installtest: building the subject into {target_dir} \
         (--no-default-features --features server,systemd)"
    );
    let status = Command::new(&cargo)
        .current_dir(root)
        .env("CARGO_TARGET_DIR", &target_dir)
        .args([
            "build",
            "--release",
            "-p",
            "gunnar-cli",
            "--no-default-features",
            "--features",
            "server,systemd",
            "--bin",
            SUBJECT_BIN,
        ])
        .status()
        .map_err(|e| format!("running `{cargo} build`: {e}"))?;
    if !status.success() {
        return Err(format!(
            "building the subject failed ({status}). There is nothing to install, and picking \
             up whatever binary happens to be at {} instead is how this crate spent 33 hours \
             testing another worktree's build.",
            binary.display()
        ));
    }
    if !binary.is_file() {
        return Err(format!(
            "cargo reported success and there is no binary at {}",
            binary.display()
        ));
    }
    Ok(binary)
}

/// The first line of `<binary> --version`.
pub fn version_of(binary: &Path) -> Result<String, String> {
    let text = run_ok(
        Command::new(binary).arg("--version"),
        &format!("{} --version", binary.display()),
    )?;
    let line = text.lines().next().unwrap_or_default().trim().to_string();
    if line.is_empty() {
        return Err(format!(
            "{} --version printed nothing. A binary that cannot name itself cannot be held to \
             anything measured against it.",
            binary.display()
        ));
    }
    Ok(line)
}

fn run_ok(cmd: &mut Command, what: &str) -> Result<String, String> {
    let out = cmd.output().map_err(|e| format!("{what}: {e}"))?;
    if !out.status.success() {
        return Err(format!(
            "{what} exited {}: {}",
            out.status,
            String::from_utf8_lossy(&out.stderr).trim()
        ));
    }
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Two worktrees on this box, deliberately not existing, so this says
    /// something about the derivation rather than about the desktop's filesystem.
    const LANE_A: &str = "~/scratch/wt/__lane_a__/gunnar";
    const LANE_B: &str = "~/scratch/wt/__lane_b__/gunnar";

    /// **Two worktrees must not get one build directory.** The constant this
    /// replaced made these two calls equal, and that is the whole defect:
    /// cargo uplifts to `<target>/release/gunnar` under one name whatever
    /// worktree built it, so a second worktree finds the first worktree's binary
    /// already sitting there and — nothing of its own having changed — takes
    /// it, `build.rs`'s baked `git describe` and all.
    ///
    /// Both lanes are called `gunnar`. That is not a contrived case, it is what
    /// `git worktree list` on this box actually looks like, and it is precisely
    /// what a basename-only slug would get wrong.
    #[test]
    fn two_worktrees_get_two_directories() {
        assert_ne!(
            target_dir_for(Path::new(LANE_A)),
            target_dir_for(Path::new(LANE_B)),
            "two worktrees resolved to one build directory. That is the shared \
             ~/scratch/cargo/gunnar-install this was split apart to end, and \
             it is how a worktree comes to refuse a binary stamped with somebody else's HEAD"
        );
    }

    /// The identity-value trap in the other direction. A directory that changed
    /// on every call — a pid, a timestamp, a random — would satisfy the test
    /// above perfectly and throw away every incremental build the lane has.
    #[test]
    fn one_worktree_gets_one_directory_every_time() {
        assert_eq!(
            target_dir_for(Path::new(LANE_A)),
            target_dir_for(Path::new(LANE_A)),
            "one worktree got two different build directories, so nothing is ever \
             incremental and no `GUNNAR_INSTALLTEST_NO_BUILD=1` run can find its subject"
        );
    }

    /// Still on md1, still under one root, and it still says whose it is.
    #[test]
    fn the_directory_is_under_the_scratch_root_and_names_its_lane() {
        let d = target_dir_for(Path::new(LANE_A));
        assert!(
            d.starts_with(crate::expand_home(TARGET_ROOT)),
            "{} left the scratch root. Build scratch belongs on md1",
            d.display()
        );
        assert_ne!(
            d,
            crate::expand_home(TARGET_ROOT),
            "the derivation collapsed back onto the shared root itself"
        );
        let leaf = d
            .file_name()
            .and_then(|s| s.to_str())
            .expect("a leaf")
            .to_string();
        assert!(
            leaf.starts_with("gunnar-"),
            "the leaf `{leaf}` does not name its worktree, so `ls` cannot say whose \
             directory is whose"
        );
        assert!(
            leaf.len() > "gunnar-".len(),
            "the leaf `{leaf}` is the basename alone, and every worktree here is called \
             `gunnar`"
        );
    }

    /// The version match must be able to say NO. A stamp from another commit is
    /// the failure this module exists for, and a comparison that accepts it is
    /// worth nothing — so both directions are asserted, because a function that
    /// answered "different" to everything would satisfy either one alone.
    #[test]
    fn a_version_from_another_build_is_refused_and_the_matching_one_is_not() {
        let subject = Subject {
            path: PathBuf::from("/nowhere/gunnar"),
            version: "gunnar 0.1.0 (v1.0-4-g008d74b, built 2026-08-05T06:26:30Z)".to_string(),
            head: "008d74b".to_string(),
        };
        assert!(subject
            .assert_installed(&subject.version, "in the container")
            .is_ok());
        // Trailing whitespace is not a different build.
        assert!(subject
            .assert_installed(&format!("{}\n", subject.version), "in the container")
            .is_ok());

        let other = "gunnar 0.1.0 (v1.0-3-g433a659, built 2026-08-04T20:10:00Z)";
        let err = subject
            .assert_installed(other, "in the guest")
            .expect_err("a different commit is a different build");
        assert!(err.contains("433a659") && err.contains("008d74b"), "{err}");

        // The trap this replaced: the old check was
        // `version.to_lowercase().contains("gunnar")`, which every string here
        // passes, including the wrong one.
        assert!(other.to_lowercase().contains("gunnar"));
    }
}