installtest/src/chain.rs
//! The **chain** arm: push, build, ship, pull, verify — against an *installed*
//! binary (roadmap phase 5).
//!
//! The other three arms end at [`Probe::push_then_clone`]: gunnar is a git
//! server and the object graph came back. This one goes the rest of the way, and
//! the assertion is the roadmap's own sentence:
//!
//! > A pulled image digest verifies the whole chain, back to a line of source.
//!
//! ```text
//! git push ──▶ gunnar build run ──▶ build image ──▶ attest head
//! │
//! gunnar verify ◀── registry pull ◀── registry push ┘
//! ```
//!
//! # Why this arm exists when `crates/gunnar-cli/tests/registry_http.rs` passes
//!
//! That test links the crates. This one runs **the shipped binary's own CLI**,
//! against a data root it bootstrapped itself, with nothing of the test
//! harness inside the process. Two things can only fail here: a verb wired to
//! the wrong function, and a feature that is on in the test profile and off in
//! the release binary. Both have happened in this repository — `feature_split`
//! exists because of the second — and neither is visible from inside.
//!
//! # MEASURED HERE: the chain's admin verbs cannot run beside a live server
//!
//! The first shape of this arm ran `gunnar repo create` against a running
//! instance and got:
//!
//! ```text
//! opening the control store …/control.redb: Database already open.
//! Cannot acquire lock.
//! ```
//!
//! That is not a bug and it is not a limitation of this harness — it is the
//! store's single-writer discipline, and it is *why* `gunnar serve --runner`
//! spawns the build runner **inside** the server process rather than beside it.
//! Every verb that writes to the control store (`repo create`, `build run`,
//! `attest head`, `registry push`) is an offline verb.
//!
//! So the arm has three phases and says so: **up → push → down**, then the
//! chain offline, then **up → pull → verify**. A deployment does the middle
//! phase in-process; this arm does it out of process because that is the only
//! way to drive the *shipped CLI verbs*, which is what it exists to test. The
//! third phase pulls over the OCI HTTP API against the live server, so the
//! pull is a real one and not a read of a directory.
//!
//! # It fails closed at four separate points, and each one is asserted
//!
//! The green path is not the proof. What is asserted is that the registry
//! **refuses** before each link is in place, in the order the links are made:
//! no build record, no head over it, and — the one that matters — a chain whose
//! `verify` reports an unproven link exits non-zero. A registry that took
//! anything would sail through the happy path.
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::bare::{Instance, UpOptions};
use crate::{ArmResult, Exchange, Fault, MIN_OBJECTS};
/// A step of the chain that ran, and what it said.
#[derive(Debug, Clone)]
pub struct Step {
pub name: &'static str,
/// What it cost, so `stages` reports the run it asserts rather than a
/// second, separately-timed one.
pub ms: u128,
pub status: Option<i32>,
pub stdout: String,
pub stderr: String,
}
impl Step {
/// Did it exit zero?
pub fn ok(&self) -> bool {
self.status == Some(0)
}
/// One line for the report.
pub fn line(&self) -> String {
format!(
" {:<22} {:>6} ms exit {}",
self.name,
self.ms,
match self.status {
Some(code) => code.to_string(),
None => "signal".to_owned(),
}
)
}
}
/// What the arm produced.
#[derive(Debug, Clone, Default)]
pub struct ChainResult {
pub steps: Vec<Step>,
/// The image manifest's digest, as `gunnar build image` printed it.
pub image: String,
/// The commit the source was pushed at.
pub commit: String,
}
/// Run one `gunnar` verb against a data root.
///
/// Takes the two paths rather than an [`Instance`], because every verb here is
/// an **offline** verb and there is deliberately no server up when they run.
fn gunnar(at: &Offline, name: &'static str, args: &[&str]) -> Step {
let mut cmd = Command::new(&at.binary);
cmd.arg("--data-root").arg(&at.data);
for arg in args {
cmd.arg(arg);
}
let started = std::time::Instant::now();
let output = cmd.output();
let ms = started.elapsed().as_millis();
match output {
Ok(out) => Step {
name,
ms,
status: out.status.code(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
},
Err(err) => Step {
name,
ms,
status: None,
stdout: String::new(),
stderr: format!("could not run {}: {err}", at.binary.display()),
},
}
}
/// Where the offline verbs run: the shipped binary and the data root it owns.
///
/// A pair and not an [`Instance`], because an `Instance` is a *running* server
/// and the whole point of phase two is that there is not one.
struct Offline {
binary: PathBuf,
data: PathBuf,
}
/// A repository that builds one file, in a working tree of its own.
///
/// Deliberately more than one blob, for the same reason the shared fixture is:
/// see [`MIN_OBJECTS`]. Three files and a recipe is enough that a server which
/// shipped one object would not survive the clone the other arms do.
fn fixture(work: &Path) -> Result<PathBuf, String> {
let dir = work.join("chain-src");
std::fs::create_dir_all(&dir).map_err(|err| format!("{}: {err}", dir.display()))?;
let write = |name: &str, body: &str| -> Result<(), String> {
std::fs::write(dir.join(name), body).map_err(|err| format!("{name}: {err}"))
};
write("src.txt", "one\ntwo\nthree\n")?;
write(
"build.sh",
"set -e\nmkdir -p out\ncp src.txt out/app\nchmod +x out/app\n",
)?;
write(
"gunnar-build.toml",
"name = \"chain\"\ncommand = \"bash\"\nargs = [\"build.sh\"]\nartifacts = [\"out/app\"]\n",
)?;
Ok(dir)
}
fn git(dir: &Path, ssh: &str, args: &[&str]) -> Result<String, String> {
let out = Command::new("git")
.current_dir(dir)
.env("GIT_SSH_COMMAND", ssh)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_AUTHOR_NAME", "installtest")
.env("GIT_AUTHOR_EMAIL", "installtest@example.invalid")
.env("GIT_COMMITTER_NAME", "installtest")
.env("GIT_COMMITTER_EMAIL", "installtest@example.invalid")
.args(args)
.output()
.map_err(|err| format!("git {}: {err}", args.join(" ")))?;
if !out.status.success() {
return Err(format!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
/// **The arm.**
///
/// `fault` is honoured where it can be: [`Fault::WrongPort`] and
/// [`Fault::MissingBinary`] are about bringing the instance up and are handled
/// by [`Instance::up`]; this arm adds no fault of its own, because its
/// red-first proofs are the refusals inside the happy path rather than injected
/// defects — the registry saying no is not a fault, it is the product.
fn walk(binary: &Path, root: &Path, fault: Fault) -> Result<(ChainResult, Exchange), String> {
let mut result = ChainResult::default();
// Phase one: a server, and the source pushed into it.
let options = |reuse: bool| UpOptions {
ssh_port: None,
// Provisioned, not created afterwards: the control store is
// single-writer and a running server holds it. See the header.
extra_repos: vec!["demo/chain".to_owned()],
// Bound, and a free port chosen before the process starts: the OCI
// registry answers on the HTTP listener, and a registry nothing can
// reach proves nothing about a pull.
http_port: Some(0),
grpc: false,
reuse_data: reuse,
exec: if fault == Fault::MissingBinary {
Some(root.join("gunnar-not-installed"))
} else {
None
},
extra_principals: 0,
max_concurrent_transfers: None,
max_transfers_per_principal: None,
hard_per_principal_limit: false,
};
let instance = Instance::up(binary, root, &options(false))?;
// ── 1. the source, pushed with stock git over SSH ──────────────────────
let probe = instance.probe();
let ssh = probe.ssh_command();
let dir = fixture(&instance.work)?;
git(&dir, &ssh, &["init", "--quiet", "-b", "main", "."])?;
git(&dir, &ssh, &["add", "."])?;
// **Signed**, with the same key the push authenticates with — which is a
// key gunnar has in its identity table, so link 2 of the chain has something to
// check against. An unsigned commit is not a broken chain, it is an
// UNPROVEN link, and `verify` reports INCOMPLETE and exits non-zero for it.
// That is the correct behaviour and it is why this arm signs: the sentence
// being proved is "green the whole chain", and a hole is not green.
let signing_key = instance.key.display().to_string();
git(
&dir,
&ssh,
&[
"-c",
"gpg.format=ssh",
"-c",
&format!("user.signingkey={signing_key}"),
"commit",
"--quiet",
"--gpg-sign",
"-m",
"the source",
],
)?;
let url = instance.url("demo/chain");
git(&dir, &ssh, &["push", "--quiet", &url, "main"])?;
result.commit = git(&dir, &ssh, &["rev-parse", "HEAD"])?;
// ── phase two: the server steps aside ──────────────────────────────────
//
// Every verb below writes to the control store, and the store is
// single-writer. See the header: this is the store's discipline, measured,
// and the reason `serve --runner` builds in-process.
let at = Offline {
binary: instance.binary.clone(),
data: instance.data.clone(),
};
let work = instance.work.clone();
instance.down("TERM", false).check()?;
// ── 2. refused BEFORE anything is built ────────────────────────────────
//
// Asked first, on purpose. `admit` is the registry's one question, and a
// server that answered it yes here would make every later green
// meaningless.
// `build admit` takes a PATH and hashes it itself — a digest a caller
// supplies is a digest a caller can choose — so the arm offers it a real
// file that no build ever produced.
let stranger = work.join("nobody-built-this");
std::fs::write(&stranger, b"bytes from nowhere\n")
.map_err(|err| format!("{}: {err}", stranger.display()))?;
let refused = gunnar(
&at,
"admit (unbuilt)",
&[
"build",
"admit",
"--artifact",
stranger.to_str().unwrap_or(""),
],
);
if refused.ok() {
return Err(format!(
"the registry admitted an artifact nobody built. `No artifact without valid, \
logged provenance` is the product.\n{}{}",
refused.stdout, refused.stderr
));
}
result.steps.push(refused);
// ── 3. build it, in the sandbox, through the shipped binary ────────────
let build = gunnar(
&at,
"build run",
&[
"build",
"run",
"--repo",
"demo/chain",
"--at",
&result.commit,
"--workspace",
work.join("builds").to_str().unwrap_or("builds"),
"--builder",
"tester",
],
);
if !build.ok() {
return Err(format!(
"`gunnar build run` failed against the installed binary.\n{}{}",
build.stdout, build.stderr
));
}
result.steps.push(build);
// `<workspace>/<namespace>/<repo>/<commit>/` — the runner materialises each
// commit into a directory of its own, so two builds of two commits never
// share a tree.
let built_binary = work
.join("builds")
.join("demo")
.join("chain")
.join(&result.commit)
.join("out")
.join("app");
if !built_binary.exists() {
return Err(format!(
"`gunnar build run` exited zero and produced no {}",
built_binary.display()
));
}
// ── 4. still refused: a record with no signed head over it ─────────────
//
// The pedantic one, and the one worth being pedantic about: an inclusion
// proof nobody signed is arithmetic the server did about itself.
let unheaded = gunnar(
&at,
"admit (unheaded)",
&[
"build",
"admit",
"--artifact",
built_binary.to_str().unwrap_or(""),
],
);
if unheaded.ok() {
return Err(format!(
"the registry admitted a record no signed tree head covers.\n{}{}",
unheaded.stdout, unheaded.stderr
));
}
result.steps.push(unheaded);
// ── 5. head both logs, then the image ──────────────────────────────────
for (name, args) in [
("attest head", vec!["attest", "head"]),
("attest head --builds", vec!["attest", "head", "--builds"]),
] {
let step = gunnar(&at, name, &args);
if !step.ok() {
return Err(format!(
"`gunnar {}` failed.\n{}{}",
args.join(" "),
step.stdout,
step.stderr
));
}
result.steps.push(step);
}
let admitted = gunnar(
&at,
"admit (headed)",
&[
"build",
"admit",
"--artifact",
built_binary.to_str().unwrap_or(""),
],
);
if !admitted.ok() {
return Err(format!(
"an attested artifact under a signed head was still refused, so the gate refuses \
everything and the refusals above proved nothing.\n{}{}",
admitted.stdout, admitted.stderr
));
}
result.steps.push(admitted);
let image = gunnar(
&at,
"build image",
&[
"build",
"image",
"--artifact",
built_binary.to_str().unwrap_or(""),
"--entrypoint",
"/app",
"--builder",
"tester",
],
);
if !image.ok() {
return Err(format!(
"`gunnar build image` failed.\n{}{}",
image.stdout, image.stderr
));
}
result.image = sha256_in(&image.stdout).ok_or_else(|| {
format!(
"`gunnar build image` printed no manifest digest.\n{}",
image.stdout
)
})?;
result.steps.push(image);
// The image's own record needs a head too, and that is the third refusal:
// one link at a time, all the way up.
let head = gunnar(&at, "attest head --builds", &["attest", "head", "--builds"]);
if !head.ok() {
return Err(format!(
"heading the build log failed.\n{}{}",
head.stdout, head.stderr
));
}
result.steps.push(head);
// ── 6. land it, and pull it back ───────────────────────────────────────
let push = gunnar(
&at,
"registry push",
&[
"registry",
"push",
"--name",
"demo/chain",
"--tag",
"v1",
"--digest",
&result.image,
],
);
if !push.ok() {
return Err(format!(
"the registry refused an image that is attested and headed.\n{}{}",
push.stdout, push.stderr
));
}
result.steps.push(push);
// ── 7. THE ASSERTION: the pull verifies the chain to a line ────────────
let pull = gunnar(
&at,
"registry pull",
&[
"registry",
"pull",
"--name",
"demo/chain",
"--tag",
"v1",
"--line",
"src.txt:2",
],
);
if !pull.ok() {
return Err(format!(
"the pulled image's chain is not green all the way to a line of source. This is \
the roadmap's acceptance sentence and it is what this arm exists for.\n{}{}",
pull.stdout, pull.stderr
));
}
// An exit code alone would pass against a `pull` that printed nothing and
// returned zero, which is the identity-value trap. The chain has to be
// *reported*, and it has to reach the commit that was pushed.
let said = format!("{}{}", pull.stdout, pull.stderr);
if !said.contains(&result.commit) {
return Err(format!(
"`registry pull --line` exited zero without naming the commit that was pushed \
({}), so it did not walk the chain it claims to have walked.\n{said}",
result.commit
));
}
result.steps.push(pull);
// ── 8. up again, and it is still a git server ──────────────────────────
//
// The other arms' assertion, run last rather than instead: a chain that
// verifies on a server that stopped serving git is not a forge. On the same
// data root, so this is the repository the chain was built out of and not a
// fresh one.
let instance = Instance::up(binary, root, &options(true))?;
let probe = instance.probe();
// ── 9. and the image pulls over the WIRE, not out of a directory ───────
//
// Everything above is the shipped CLI against a data root. This is the OCI
// Distribution API on the live server's HTTP listener — the request
// `docker pull` makes — and it is what turns "the registry holds it" into
// "a client can get it".
let port = instance
.http_port
.ok_or_else(|| "the instance was brought up without an HTTP listener".to_string())?;
let over_the_wire = http_get(port, "/v2/demo/chain/manifests/v1")?;
// Compared against the manifest `registry pull` printed, byte for byte.
// No hashing here on purpose: this crate has no dependencies and a
// hand-rolled SHA-256 would be a second implementation of the thing the
// server is being asked about. The offline pull already checked the digest
// — `Registry::get_blob` refuses bytes that do not hash to their own name —
// so what is left to prove is that the *wire* serves the same bytes, and a
// byte comparison says exactly that and nothing more.
let expected = result
.steps
.iter()
.find(|step| step.name == "registry pull")
.and_then(|step| {
step.stdout
.lines()
.find(|line| line.starts_with("{\"schemaVersion\""))
})
.ok_or_else(|| "`registry pull` printed no manifest to compare against".to_string())?;
if String::from_utf8_lossy(&over_the_wire).trim() != expected {
return Err(format!(
"the manifest served over `/v2/` is not the one the registry holds.\n wire: \
{}\n held: {expected}",
String::from_utf8_lossy(&over_the_wire).trim()
));
}
// The version probe answers too, which is what a client asks first and what
// the readiness probe in `deploy/kubernetes/gunnar.yaml` hits.
let probe_body = http_get(port, "/v2/")?;
if probe_body != b"{}" {
return Err(format!(
"`GET /v2/` answered {:?}, not the empty document every OCI client expects",
String::from_utf8_lossy(&probe_body)
));
}
// `push_then_clone` compares the two object graphs itself and refuses a
// match below the floor; the count is re-checked here so this arm's own
// report says what it saw rather than trusting a call that returned `Ok`.
let exchange = probe.push_then_clone("demo/hello", Fault::None)?;
if exchange.objects < MIN_OBJECTS {
return Err(format!(
"the round trip moved {} object(s), below the floor of {MIN_OBJECTS}",
exchange.objects
));
}
Ok((result, exchange))
}
/// **The arm**, in the shape every other arm reports in.
///
/// `outcome` carries the closing round trip, so the shared renderer prints an
/// object count for this arm the way it does for the other three; the chain's
/// own steps go into `stages`, which is where a bench reads them.
pub fn run(binary: &Path, root: &Path, fault: Fault) -> ArmResult {
let started = std::time::Instant::now();
let (outcome, stages) = match walk(binary, root, fault) {
Ok((chain, exchange)) => {
let stages = chain
.steps
.iter()
.map(|step| (step.name, step.ms))
.collect::<Vec<_>>();
(Ok(exchange), stages)
}
Err(why) => (Err(why), Vec::new()),
};
ArmResult {
arm: "chain",
fault,
outcome,
ready: Some(started.elapsed()),
stages,
demands_red: false,
}
}
/// One `GET` against the live listener, spoken by hand.
///
/// By hand and not through a client library for the same reason
/// `crates/gunnar-cli/tests/registry_http.rs` does it: the assertion is about
/// the bytes on the socket, and a library that papered over a wrong status
/// would make this agree with a server that is wrong.
fn http_get(port: u16, path: &str) -> Result<Vec<u8>, String> {
use std::io::{Read as _, Write as _};
let host = format!("127.0.0.1:{port}");
let mut stream = std::net::TcpStream::connect(&host)
.map_err(|err| format!("connecting to {host}: {err}"))?;
let head = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
stream
.write_all(head.as_bytes())
.map_err(|err| format!("writing {path}: {err}"))?;
let mut response = Vec::new();
stream
.read_to_end(&mut response)
.map_err(|err| format!("reading {path}: {err}"))?;
let split = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.ok_or_else(|| format!("no blank line in the response to {path}"))?;
let status: u16 = String::from_utf8_lossy(&response[..split])
.split_whitespace()
.nth(1)
.and_then(|code| code.parse().ok())
.ok_or_else(|| format!("no status line in the response to {path}"))?;
if status != 200 {
return Err(format!(
"GET {path} answered {status}: {}",
String::from_utf8_lossy(&response[split + 4..]).trim()
));
}
Ok(response[split + 4..].to_vec())
}
/// The `sha256:<hex>` in some output, if there is one.
fn sha256_in(text: &str) -> Option<String> {
text.split_whitespace()
.find(|word| word.starts_with("sha256:") && word.len() == 7 + 64)
.map(|word| {
word.trim_end_matches(|c: char| !c.is_ascii_hexdigit())
.to_owned()
})
}