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