gunnar_server/runner.rs
1//! **The build runner** (roadmap phase 3).
2//!
3//! A push lands. The runner materialises exactly that commit, builds it with no
4//! network, hashes what came out, and signs a [`BuildRecord`] naming the commit
5//! it was made from — which is the join that lets `gunnar verify` walk from an
6//! artifact to a line of source.
7//!
8//! ```text
9//! RefUpdated ──▶ checkout(commit) ──▶ sandbox(build) ──▶ artifacts ──▶ signed record ──▶ log
10//! ```
11//!
12//! # The recipe is in the commit, on purpose
13//!
14//! `gunnar-build.toml` is read **out of the tree being built**, not off the
15//! runner's disk. So the recipe is part of what was pushed, part of what was
16//! signed, and part of what a verifier can go back and look at. A recipe kept
17//! beside the runner would be a build input nobody attested to — the one input
18//! that decides what every other input does.
19//!
20//! # `network = false` is a fact here, not a claim
21//!
22//! `crate::sandbox` puts the build in a network namespace of its own. The
23//! runner **refuses to build** when it cannot, rather than building anyway and
24//! recording `network: true`: a server that quietly downgrades is a server whose
25//! records mean different things on different machines. `--allow-network` is the
26//! explicit opt-out, and it is recorded in the signed record.
27//!
28//! # What it is not
29//!
30//! Not a scheduler, not a queue, not a fleet. One build at a time, in the
31//! process that watched the event. Scaling it horizontally is the phase-5 item
32//! and it is a different problem: this is the part that has to be *right*.
33
34use std::path::{Path, PathBuf};
35
36use anyhow::{Context, Result};
37use gunnar_store::git_storage_trait::{GitServe, ObjType};
38
39use crate::stores::ServerStores;
40
41/// The file a repository carries to say how it is built.
42pub const RECIPE_FILE: &str = "gunnar-build.toml";
43
44/// How a repository is built.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Recipe {
47 /// What this build is called, in the record.
48 pub name: String,
49 /// The program to run, in the checkout.
50 pub command: String,
51 pub args: Vec<String>,
52 /// Paths, relative to the checkout, that the build produces.
53 ///
54 /// Empty means **discover**: every ordinary file directly under
55 /// `target/release` that is executable. That is the default recipe's shape,
56 /// and naming them explicitly is what a repository does when it wants
57 /// something else.
58 pub artifacts: Vec<String>,
59}
60
61impl Recipe {
62 /// **The default recipe**, for a cargo workspace that carries none.
63 ///
64 /// `--locked` above all: a build that may re-resolve is a build whose
65 /// inputs are not the ones in the commit, and the SBOM this pairs with is
66 /// read from the same lockfile. Without it the two describe different
67 /// builds and nothing says so.
68 pub fn cargo_default() -> Recipe {
69 Recipe {
70 name: "cargo build --release --locked".to_owned(),
71 command: "cargo".to_owned(),
72 args: vec![
73 "build".to_owned(),
74 "--release".to_owned(),
75 "--locked".to_owned(),
76 ],
77 artifacts: Vec::new(),
78 }
79 }
80
81 /// Parse `gunnar-build.toml`.
82 ///
83 /// A **small, total** reader rather than a TOML dependency, for the reason
84 /// `gunnar_core::sbom`'s lockfile reader is one: this crate is in the
85 /// appliance graph. `key = "value"` and `key = ["a", "b"]`, and anything it
86 /// does not understand is skipped rather than guessed at.
87 pub fn parse(text: &str) -> Result<Recipe> {
88 let mut recipe = Recipe {
89 name: String::new(),
90 command: String::new(),
91 args: Vec::new(),
92 artifacts: Vec::new(),
93 };
94 for line in text.lines() {
95 let line = line.trim();
96 if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
97 continue;
98 }
99 let Some((key, value)) = line.split_once('=') else {
100 continue;
101 };
102 let value = value.trim();
103 let list = |value: &str| -> Vec<String> {
104 value
105 .trim_start_matches('[')
106 .trim_end_matches(']')
107 .split(',')
108 .map(|item| item.trim().trim_matches('"').to_owned())
109 .filter(|item| !item.is_empty())
110 .collect()
111 };
112 match key.trim() {
113 "name" => recipe.name = value.trim_matches('"').to_owned(),
114 "command" => recipe.command = value.trim_matches('"').to_owned(),
115 "args" => recipe.args = list(value),
116 "artifacts" => recipe.artifacts = list(value),
117 _ => {}
118 }
119 }
120 anyhow::ensure!(
121 !recipe.command.is_empty(),
122 "{RECIPE_FILE} names no `command`, so there is nothing to run. A recipe that builds \
123 nothing is not a smaller recipe — it is a build that reports success having done \
124 nothing."
125 );
126 if recipe.name.is_empty() {
127 recipe.name = recipe.command.clone();
128 }
129 Ok(recipe)
130 }
131}
132
133/// What one build did.
134#[derive(Debug, Clone)]
135pub struct Built {
136 pub record: gunnar_core::BuildRecord,
137 /// Where the checkout and its output are, for a caller that wants to look.
138 pub workdir: PathBuf,
139}
140
141/// Materialise `tree` into `into`, from the store and nothing else.
142///
143/// No `git checkout`, no worktree, no gix: the tree is read through `GitServe`,
144/// which is the one contract every engine has. That is also why the runner can
145/// build a repository the archive engine stores, which has no working copy on
146/// disk to check out from.
147fn checkout(
148 git: &dyn GitServe,
149 hash: gix_hash::Kind,
150 tree: gix_hash::ObjectId,
151 into: &Path,
152) -> Result<usize> {
153 std::fs::create_dir_all(into).with_context(|| format!("creating {}", into.display()))?;
154 let (kind, bytes) = git
155 .read(tree.as_bytes())?
156 .with_context(|| format!("tree {tree} is not in this repository"))?;
157 anyhow::ensure!(kind == ObjType::Tree, "{tree} is not a tree");
158
159 let mut files = 0usize;
160 for entry in gunnar_store::object::TreeIter::from_bytes(&bytes, hash).named() {
161 let entry = entry.with_context(|| format!("decoding tree {tree}"))?;
162 let name = String::from_utf8_lossy(entry.name).into_owned();
163 // The sandbox boundary starts here: a tree entry named `..` or with a
164 // separator in it would write outside the checkout, and a repository is
165 // something anybody with push access controls.
166 anyhow::ensure!(
167 name != "." && name != ".." && !name.contains('/'),
168 "tree {tree} carries an entry named {name:?}, which would write outside the checkout"
169 );
170 let path = into.join(&name);
171 match entry.kind {
172 gunnar_store::object::EntryKind::Tree => {
173 files += checkout(git, hash, entry.oid, &path)?;
174 }
175 gunnar_store::object::EntryKind::Commit => {
176 // A gitlink names a commit in another repository. There is
177 // nothing here to write, and writing an empty directory would
178 // make the build look like it had a submodule checked out.
179 continue;
180 }
181 kind => {
182 let Some((ObjType::Blob, blob)) = git.read(entry.oid.as_bytes())? else {
183 anyhow::bail!("{} is not a blob in this repository", entry.oid);
184 };
185 if kind == gunnar_store::object::EntryKind::Link {
186 // A symlink's blob is its target. Recreated as a symlink so
187 // a build that follows one behaves as it did for the author.
188 let target = String::from_utf8_lossy(&blob).into_owned();
189 let _ = std::fs::remove_file(&path);
190 std::os::unix::fs::symlink(&target, &path)
191 .with_context(|| format!("linking {}", path.display()))?;
192 } else {
193 std::fs::write(&path, &blob)
194 .with_context(|| format!("writing {}", path.display()))?;
195 if kind == gunnar_store::object::EntryKind::BlobExecutable {
196 use std::os::unix::fs::PermissionsExt as _;
197 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
198 .with_context(|| format!("chmod {}", path.display()))?;
199 }
200 }
201 files += 1;
202 }
203 }
204 }
205 Ok(files)
206}
207
208/// The recipe a checkout carries, or the default one it qualifies for.
209fn recipe_for(checkout: &Path) -> Result<Recipe> {
210 let path = checkout.join(RECIPE_FILE);
211 if path.exists() {
212 let text = std::fs::read_to_string(&path)
213 .with_context(|| format!("reading {}", path.display()))?;
214 return Recipe::parse(&text)
215 .with_context(|| format!("{} is not a usable recipe", path.display()));
216 }
217 anyhow::ensure!(
218 checkout.join("Cargo.toml").exists(),
219 "this repository carries no {RECIPE_FILE} and is not a cargo workspace, so there is no \
220 recipe and no default that fits. Add a {RECIPE_FILE} naming a `command`."
221 );
222 Ok(Recipe::cargo_default())
223}
224
225/// Every artifact the recipe produced.
226fn collect(checkout: &Path, recipe: &Recipe) -> Result<Vec<PathBuf>> {
227 if !recipe.artifacts.is_empty() {
228 let mut out = Vec::new();
229 for named in &recipe.artifacts {
230 let path = checkout.join(named);
231 anyhow::ensure!(
232 path.is_file(),
233 "the recipe names {named} and the build did not produce it. A build that \
234 reports success without its own declared output is a build nobody checked."
235 );
236 out.push(path);
237 }
238 return Ok(out);
239 }
240 // Discovery, for the default recipe: executables directly under
241 // `target/release`. Not recursive — `target/release/deps` and `build/` are
242 // cargo's own scratch, and shipping them as artifacts would attest to
243 // hundreds of files nobody asked for.
244 use std::os::unix::fs::PermissionsExt as _;
245 let dir = checkout.join("target").join("release");
246 let mut out = Vec::new();
247 if let Ok(entries) = std::fs::read_dir(&dir) {
248 for entry in entries.flatten() {
249 let path = entry.path();
250 let Ok(meta) = entry.metadata() else { continue };
251 if meta.is_file() && meta.permissions().mode() & 0o111 != 0 {
252 out.push(path);
253 }
254 }
255 }
256 out.sort();
257 anyhow::ensure!(
258 !out.is_empty(),
259 "the build produced no executable in {}. Either it built nothing, or its output is \
260 somewhere the default recipe does not look — name the artifacts in {RECIPE_FILE}.",
261 dir.display()
262 );
263 Ok(out)
264}
265
266/// **Build one commit and attest to what came out.**
267///
268/// `allow_network` is the explicit opt-out from hermeticity, and it goes into
269/// the signed record. Without it a runner that cannot enter the sandbox
270/// **fails** rather than building: a record that says `network: false` must mean
271/// it on every machine, or it means nothing on any of them.
272pub fn build_commit(
273 stores: &ServerStores,
274 repo: &gunnar_core::RepoRef,
275 commit: gix_hash::ObjectId,
276 workspace: &Path,
277 builder: &str,
278 allow_network: bool,
279) -> Result<Built> {
280 let entry = stores
281 .catalog()
282 .get(repo)?
283 .with_context(|| format!("no such repository: {repo}"))?;
284 let store = stores.repo_store(&entry)?;
285 let git = store.serve();
286 let hash = store.hash_kind();
287
288 let (kind, bytes) = git
289 .read(commit.as_bytes())?
290 .with_context(|| format!("commit {commit} is not in {repo}"))?;
291 anyhow::ensure!(kind == ObjType::Commit, "{commit} is not a commit");
292 let tree = gunnar_store::object::commit(&bytes, hash)
293 .with_context(|| format!("decoding commit {commit}"))?
294 .tree;
295
296 let workdir = workspace
297 .join(repo.namespace())
298 .join(repo.name())
299 .join(commit.to_string());
300 // A fresh checkout every time. Reusing one would make the build depend on
301 // what the *last* build left behind, which is the first thing that makes a
302 // "reproducible" build not one.
303 let _ = std::fs::remove_dir_all(&workdir);
304 let files = checkout(git, hash, tree, &workdir)?;
305 anyhow::ensure!(
306 files > 0,
307 "commit {commit} has an empty tree; there is nothing to build"
308 );
309
310 let recipe = recipe_for(&workdir)?;
311
312 if !allow_network {
313 if let Err(err) = crate::sandbox::available() {
314 anyhow::bail!(
315 "this build was to be hermetic and the sandbox cannot be entered: {}\n\n\
316 Refusing rather than building with the network up. A record that says \
317 `network: false` has to mean it on every machine. Pass --allow-network to \
318 build anyway; the record will say so.",
319 err.explain()
320 );
321 }
322 }
323
324 let started = gunnar_grpc::bus::now_unix_ms();
325 let mut command = hardened_command(&recipe.command, &workdir, allow_network);
326 command
327 .args(&recipe.args)
328 .env("CARGO_NET_OFFLINE", "true")
329 .env("GUNNAR_BUILD_COMMIT", commit.to_string())
330 .env("GUNNAR_BUILD_REPO", repo.to_string());
331 let output = command
332 .output()
333 .with_context(|| format!("running {}", recipe.command))?;
334 let finished = gunnar_grpc::bus::now_unix_ms();
335
336 if let Some(err) = output
337 .status
338 .code()
339 .and_then(crate::sandbox::error_from_exit)
340 {
341 anyhow::bail!("the build never started: {}", err.explain());
342 }
343 anyhow::ensure!(
344 output.status.success(),
345 "the build failed ({}):\n{}",
346 output.status,
347 String::from_utf8_lossy(&output.stderr)
348 );
349
350 let artifacts = collect(&workdir, &recipe)?;
351 let subjects = artifacts
352 .iter()
353 .map(|path| -> Result<gunnar_core::Subject> {
354 Ok(gunnar_core::Subject {
355 name: path
356 .strip_prefix(&workdir)
357 .unwrap_or(path)
358 .display()
359 .to_string(),
360 sha256: gunnar_core::provenance::digest_file(path)
361 .with_context(|| format!("hashing {}", path.display()))?,
362 })
363 })
364 .collect::<Result<Vec<_>>>()?;
365
366 // The lockfile is a **material**: it is what decided every dependency, and
367 // it is in the commit, so naming it here is what lets the SBOM beside this
368 // record be checked against the same bytes.
369 let mut materials = Vec::new();
370 let lock = workdir.join("Cargo.lock");
371 if lock.is_file() {
372 materials.push(gunnar_core::Subject {
373 name: "Cargo.lock".to_owned(),
374 sha256: gunnar_core::provenance::digest_file(&lock)?,
375 });
376 }
377
378 let record = gunnar_core::BuildRecord {
379 builder: builder.to_owned(),
380 source_repo: repo.to_string(),
381 source_commit: commit.to_string(),
382 recipe: recipe.name.clone(),
383 toolchain: toolchain(&recipe.command, &workdir, allow_network),
384 network: allow_network,
385 materials,
386 subjects,
387 started_at_unix_ms: started,
388 finished_at_unix_ms: finished,
389 };
390
391 let key = stores.provenance_key()?;
392 let signed = gunnar_core::provenance::sign_build(&key, record.clone())
393 .context("signing the build record")?;
394 let mut line = serde_json::to_string(&signed)?;
395 line.push('\n');
396 let _framed = gunnar_core::txlog::sink::append_line(stores.build_log().records_path(), line.trim_end_matches('\n'))?;
397 use std::io::Write as _;
398 let mut file = std::fs::OpenOptions::new()
399 .create(true)
400 .append(true)
401 .open(stores.build_log().records_path())?;
402 file.write_all(line.as_bytes())?;
403
404 Ok(Built { record, workdir })
405}
406
407/// **The one constructor of every process a build runs.**
408///
409/// GUN-006. `build_commit` built its `Command` with `env_clear`,
410/// `current_dir(&workdir)` and `pre_exec(enter_sandbox)`, and then
411/// [`toolchain`] ran the SAME attacker-named program a second time with
412/// `Command::new(command).arg("--version")` — no cleared environment, no
413/// working directory, no sandbox. One hardened exec and one bare one, of a
414/// `recipe.command` read out of the pushed tree and validated against nothing
415/// but `!is_empty()`.
416///
417/// The escape was worth more than the build: the bare child ran as the server's
418/// uid with the network up, so it could read `$GUNNAR_SECRET_KEK` or
419/// `secrets.kek` from disk and decrypt the sealed store — the SSH host key and
420/// the provenance signing key — and then sign `BuildRecord`s of its own.
421///
422/// So the hardening is a function both call sites must go through, and
423/// `the_runner_has_exactly_one_process_constructor` holds a third from being
424/// added beside them. What is NOT here is the build's own environment
425/// (`CARGO_NET_OFFLINE`, `GUNNAR_BUILD_*`): those are facts about a build and
426/// `toolchain` is not one, and adding them here would make the version probe
427/// claim to be a build step.
428fn hardened_command(program: &str, workdir: &Path, allow_network: bool) -> std::process::Command {
429 let mut command = std::process::Command::new(program);
430 command
431 .current_dir(workdir)
432 // A clean-ish environment: the build's inputs should be the commit, not
433 // the operator's shell. `PATH` and `HOME` stay because a toolchain
434 // needs to be findable and cargo needs somewhere to put its caches.
435 .env_clear()
436 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
437 .env("HOME", std::env::var_os("HOME").unwrap_or_default());
438 if !allow_network {
439 use std::os::unix::process::CommandExt as _;
440 // The hook is bound OUTSIDE the `unsafe` block on purpose: an `unsafe`
441 // block extends lexically into a closure written inside it, so nesting
442 // the two made the inner one redundant (`unused_unsafe`) and its SAFETY
443 // note unenforced. Bound here, each `unsafe` covers exactly one call.
444 let enter_sandbox = || {
445 // SAFETY: `pre_exec` runs in the forked child, single-threaded,
446 // before `exec`.
447 if let Err(err) = unsafe { crate::sandbox::enter() } {
448 crate::sandbox::leave_child(crate::sandbox::exit_code(err));
449 }
450 Ok(())
451 };
452 // SAFETY: `sandbox::enter` allocates nothing and takes no lock, which
453 // is the whole requirement `pre_exec` imposes.
454 unsafe {
455 command.pre_exec(enter_sandbox);
456 }
457 }
458 command
459}
460
461/// What the toolchain says it is, or an honest blank.
462///
463/// Asked of the tool that ran, not read from a config: a `rust-toolchain.toml`
464/// says what was *requested* and this says what answered. Empty when the tool
465/// will not say, because a made-up version in a signed record is worse than no
466/// version at all.
467///
468/// Through [`hardened_command`], because this runs the pushed tree's own
469/// `recipe.command` — see GUN-006 — and its stdout goes verbatim into the
470/// SIGNED `toolchain` field of the build record.
471fn toolchain(command: &str, workdir: &Path, allow_network: bool) -> String {
472 hardened_command(command, workdir, allow_network)
473 .arg("--version")
474 .output()
475 .ok()
476 .filter(|out| out.status.success())
477 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
478 .unwrap_or_default()
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[test]
486 fn a_recipe_parses_and_one_with_no_command_is_refused() {
487 let recipe = Recipe::parse(
488 r#"
489 name = "the appliance"
490 command = "make"
491 args = ["-j", "4"]
492 artifacts = ["out/gunnar"]
493 "#,
494 )
495 .expect("a usable recipe");
496 assert_eq!(recipe.name, "the appliance");
497 assert_eq!(recipe.command, "make");
498 assert_eq!(recipe.args, vec!["-j", "4"]);
499 assert_eq!(recipe.artifacts, vec!["out/gunnar"]);
500
501 // A recipe that builds nothing must be refused rather than treated as a
502 // smaller one: it would report success having done nothing.
503 let err = Recipe::parse("name = \"empty\"").expect_err("no command");
504 assert!(format!("{err}").contains("command"), "{err}");
505 }
506
507 #[test]
508 fn a_recipe_with_no_name_is_named_after_its_command() {
509 let recipe = Recipe::parse("command = \"cargo\"").expect("usable");
510 assert_eq!(recipe.name, "cargo");
511 }
512
513 /// **GUN-006: every process a build runs goes through one constructor.**
514 ///
515 /// The finding was not that `toolchain` was written carelessly — it was
516 /// that there were TWO places that exec a `recipe.command` read out of the
517 /// pushed tree, and only one of them was hardened. A fix that hardened the
518 /// second one would leave a third free to forget again, which is why the
519 /// hardening became a function and why this counts.
520 ///
521 /// Source-level on purpose. The property is "there is one constructor",
522 /// and no behaviour of a single call can assert that; the file is the
523 /// evidence. `include_str!` reads THIS file at compile time, so the guard
524 /// cannot drift from what it guards.
525 ///
526 /// Mutation-proven 2026-09-11: put `Command::new` back in `toolchain` and
527 /// the count is 2.
528 #[test]
529 fn the_runner_has_exactly_one_process_constructor() {
530 let source = include_str!("runner.rs");
531 let constructors = source
532 .lines()
533 // A doc comment quoting the old line is not a call site, and
534 // neither is a string literal — including the needle on this very
535 // line, which is why the quote character is spelled rather than
536 // written.
537 .filter(|line| !line.trim_start().starts_with("//"))
538 .filter(|line| !line.contains(char::from(34)))
539 .filter(|line| line.contains("std::process::Command::new("))
540 .count();
541 assert_eq!(
542 constructors, 1,
543 "every exec a build performs must go through `hardened_command`; \
544 found {constructors} fully-qualified process constructors in runner.rs"
545 );
546 // …and the spelling cannot be dodged by importing the type, which
547 // would make the count above read 1 while a second exec sat beside it.
548 // Spelled in two halves so the needle is not in the file it searches.
549 // A guard that trips on its own source proves nothing about the code
550 // it guards.
551 assert!(
552 !source.contains(concat!("use std::process::", "Command;")),
553 "runner.rs imports the process builder by name; the count above \
554 only sees the fully-qualified spelling"
555 );
556 }
557
558 /// The hardening is applied, and it is applied to a process that can be
559 /// watched: a cleared environment and the working directory the build was
560 /// checked out into.
561 ///
562 /// `/usr/bin/env` with no arguments prints the environment it was given,
563 /// and `/bin/pwd` prints the directory it was started in. Both are
564 /// coreutils and both are already assumed by the suites that drive `git`
565 /// and `ssh`; neither needs a shell.
566 #[test]
567 fn the_hardened_command_clears_the_environment_and_sets_the_directory() {
568 let dir = tempfile::tempdir().expect("a temp dir");
569 // A marker the caller's environment carries and the child must not.
570 // Set on the CHILD-to-be's parent — this process — through the same
571 // `Command` API, so no global state is mutated under cargo's test
572 // threads.
573 let seen = |program: &str| -> Option<String> {
574 let out = hardened_command(program, dir.path(), true)
575 .env("GUNNAR_TEST_LEAK", "this must not cross")
576 .output()
577 .ok()?;
578 out.status
579 .success()
580 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
581 };
582 // `.env()` after `env_clear()` puts the marker back deliberately: it
583 // proves the child is readable at all, so an empty answer below cannot
584 // be mistaken for a cleared environment.
585 let Some(environment) = seen("/usr/bin/env") else {
586 return; // no coreutils on this box; nothing to measure
587 };
588 assert!(
589 environment.contains("GUNNAR_TEST_LEAK=this must not cross"),
590 "the fixture cannot see the child's environment at all: {environment:?}"
591 );
592 // …and nothing else of the caller's. `PATH` and `HOME` are the two
593 // `hardened_command` puts back by name.
594 for line in environment.lines().filter(|l| !l.is_empty()) {
595 let name = line.split('=').next().unwrap_or_default();
596 assert!(
597 matches!(name, "PATH" | "HOME" | "GUNNAR_TEST_LEAK"),
598 "the build child inherited {name} from the operator's shell"
599 );
600 }
601
602 if let Some(cwd) = seen("/bin/pwd") {
603 assert_eq!(
604 std::fs::canonicalize(cwd.trim()).ok(),
605 std::fs::canonicalize(dir.path()).ok(),
606 "the build child did not start in the checkout"
607 );
608 }
609 }
610
611 #[test]
612 fn the_default_recipe_locks_its_dependencies() {
613 // `--locked` is the whole point of the default: without it the build
614 // may re-resolve, and the SBOM read from the lockfile then describes a
615 // different build with nothing saying so.
616 assert!(
617 Recipe::cargo_default()
618 .args
619 .contains(&"--locked".to_owned()),
620 "the default cargo recipe does not pass --locked"
621 );
622 }
623}
624
625// ───────────────────────────────────────────────────────────────────────────
626// Watching
627// ───────────────────────────────────────────────────────────────────────────
628
629/// How the runner is configured when it watches rather than being told.
630#[derive(Debug, Clone)]
631pub struct WatchConfig {
632 /// Where checkouts and build output go.
633 pub workspace: PathBuf,
634 /// The ACL principal every record is attributed to.
635 pub builder: String,
636 /// Build with the network up. Off by default; recorded when on.
637 pub allow_network: bool,
638 /// Only build these refs. Empty means every branch.
639 ///
640 /// A list and not a pattern: a pattern language here would be a second
641 /// ref-matching implementation beside `gunnar-policy`'s, and two of those
642 /// can disagree about which branch is protected.
643 pub refs: Vec<String>,
644 /// Stop after this many builds. `None` watches for ever; a bound is what a
645 /// test uses, and what an operator uses to drain one queue and stop.
646 pub max_builds: Option<usize>,
647}
648
649/// What one watched build turned into.
650#[derive(Debug)]
651pub enum Watched {
652 /// A build ran and attested.
653 Built(Box<Built>),
654 /// A ref moved and the runner did not build it.
655 Skipped {
656 repo: String,
657 r#ref: String,
658 why: String,
659 },
660 /// A build was attempted and failed. **Not fatal to the watch**: one
661 /// repository's broken build must not stop every other repository's.
662 Failed {
663 repo: String,
664 commit: String,
665 why: String,
666 },
667}
668
669/// Should this ref update be built?
670fn wanted(config: &WatchConfig, ref_name: &str) -> bool {
671 // Branches only. A tag move is not a new state of the code — the commit it
672 // points at was already pushed and already built — and a runner that built
673 // both would attest twice to one commit under two recipes' worth of noise.
674 if !ref_name.starts_with("refs/heads/") {
675 return false;
676 }
677 config.refs.is_empty() || config.refs.iter().any(|want| want == ref_name)
678}
679
680/// **Watch the event bus and build what lands.**
681///
682/// Blocking, and deliberately so: the runner is one build at a time in the
683/// process that watched the event. A queue and a pool are the phase-5 problem;
684/// this is the part that has to be right, and "right" is easier to see when
685/// there is one of it.
686///
687/// Every outcome — built, skipped, failed — is handed to `report` rather than
688/// logged and dropped, so a caller can assert on what happened. A build that
689/// fails does **not** stop the watch: one repository's broken recipe must not
690/// take every other repository's builds with it.
691pub fn watch(
692 stores: &ServerStores,
693 bus: &gunnar_grpc::EventBus,
694 config: &WatchConfig,
695 mut report: impl FnMut(Watched),
696) -> Result<usize> {
697 // `tokio_stream`, not `futures_util`: the stream is a `BroadcastStream` and
698 // tokio-stream is already resolved here. One stream crate, not two.
699 use tokio_stream::StreamExt as _;
700
701 let filter = gunnar_grpc::EventFilter {
702 namespaces: Vec::new(),
703 repos: Vec::new(),
704 kinds: vec![gunnar_proto::EventKind::RefUpdated],
705 include_attestation_object_ids: false,
706 };
707 let mut stream = bus.subscribe(filter);
708 let runtime = tokio::runtime::Builder::new_current_thread()
709 .enable_all()
710 .build()
711 .context("building a runtime for the runner's subscription")?;
712
713 let mut built = 0usize;
714 while config.max_builds.is_none_or(|max| built < max) {
715 let Some(event) = runtime.block_on(stream.next()) else {
716 break;
717 };
718 // The stream yields a `Result` because a gRPC subscriber's does. An
719 // error here is the subscription itself ending, not one event going
720 // wrong, so it stops the watch rather than being skipped past.
721 let Ok(event) = event else { break };
722 let Some(gunnar_proto::event::Payload::RefUpdated(update)) = &event.payload else {
723 continue;
724 };
725 let Some(repo) = update.repo.as_ref() else {
726 continue;
727 };
728 let key = format!("{}/{}", repo.namespace, repo.repo);
729 if !wanted(config, &update.ref_name) {
730 report(Watched::Skipped {
731 repo: key,
732 r#ref: update.ref_name.clone(),
733 why: "not a branch this runner builds".to_owned(),
734 });
735 continue;
736 }
737 let Ok(commit) = gix_hash::ObjectId::from_hex(update.new_oid.as_bytes()) else {
738 report(Watched::Skipped {
739 repo: key,
740 r#ref: update.ref_name.clone(),
741 why: format!("{} is not an object id", update.new_oid),
742 });
743 continue;
744 };
745 let Ok(repo_ref) = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo) else {
746 continue;
747 };
748 match build_commit(
749 stores,
750 &repo_ref,
751 commit,
752 &config.workspace,
753 &config.builder,
754 config.allow_network,
755 ) {
756 Ok(outcome) => {
757 built += 1;
758 report(Watched::Built(Box::new(outcome)));
759 }
760 Err(err) => report(Watched::Failed {
761 repo: key,
762 commit: commit.to_string(),
763 why: format!("{err:#}"),
764 }),
765 }
766 }
767 Ok(built)
768}