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

nordisk/gunnar

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