git-ents.gitmain
⌘K
foforge
commit b49e500
feat: extract check toolchains into the sprite and activate them via PATH

Resolves each distinct toolchain a job’s checks name, extracts it into the sprite once (its persistent filesystem is the cache across pushes), then runs a check’s command with its toolchains' bin directories prefixed onto PATH in declared order.

Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -1485,6 +1485,7 @@ "git-comment", "git-ents-core", "git-store", + "git-toolchain", "gix-actor", "gix-date", "gix-hash",
crates/git-ents-server/Cargo.toml @@ -20,6 +20,7 @@ git-anchor = { workspace = true } git-comment = { workspace = true } git-store = { workspace = true } +git-toolchain = { workspace = true } gix-actor = { workspace = true } gix-date = { workspace = true } gix-hash = { workspace = true }
docs/spec/checks.adoc @@ -76,6 +76,15 @@ work directory via `git archive` piped to `tar -x`. The worker MUST configure the `sprite` CLI from `SPRITES_TOKEN` before each run via `sprite auth setup`, so the credential stays current without restart. + +Before running a check that names a toolchain, the worker MUST resolve each +distinct toolchain named across the job's checks (<<checks.toolchains>>) and +extract it into the Sprite at a hash-keyed directory, skipping the extraction +when that directory already exists — the Sprite's persistent filesystem is +the cache, so the same toolchain is never re-extracted across pushes. A +check's command MUST then run with each of its named toolchains' `bin` +directory prefixed onto `PATH`, in the order they are declared, so an earlier +toolchain takes precedence over a later one on a name collision. -- [role="requirement", id="checks.outcomes"]
crates/git-ents-server/src/checks.rs @@ -38,6 +38,11 @@ /// Where the pushed tree is unpacked inside the Sprite. const WORKDIR: &str = "/work"; +/// Where resolved toolchains are extracted inside the Sprite, one directory +/// per tree hash (`{TOOLCHAINS_DIR}/<hash>`) — unlike [`WORKDIR`], never +/// cleared: the Sprite's persistent filesystem is the extract-once cache. +const TOOLCHAINS_DIR: &str = "/toolchains"; + /// A currently-running check's growing asciicast v2 recording, keyed by the /// repository, the commit being checked, and the check's name. pub(crate) type LiveKey = (PathBuf, ObjectId, String); @@ -282,6 +287,14 @@ return Err(e); } + let toolchain_dirs = match resolve_toolchains(&job.repo, &sprite, &runnable) { + Ok(dirs) => dirs, + Err(e) => { + finalize_error(&job.repo, job.new, &mut outcomes); + return Err(e); + } + }; + for index in ordered { let Some(check) = runnable.get(index) else { continue; @@ -300,9 +313,10 @@ let all_pass = deps.iter().all(|status| *status == Status::Pass); match &check.command { Some(command) if all_pass => { + let command = activate(command, &check.toolchains, &toolchain_dirs); let key: LiveKey = (job.repo.clone(), job.new, check.name.clone()); let buffer = live_start(live, key.clone()); - let result = run_one(&sprite, &check.name, command, &buffer); + let result = run_one(&sprite, &check.name, &command, &buffer); live_finish(live, &key); if let Some(outcome) = outcomes.get_mut(index) { outcome.status = result.status; @@ -538,6 +552,106 @@ } } +/// Resolve and extract every distinct toolchain named across `runnable`, +/// returning each name's extracted directory inside the Sprite. A failed +/// resolution (the named ref does not exist) is the one place `checks::order` +/// could not have caught it, since `refs/meta/toolchains/*` is a different +/// namespace than the check set itself. +fn resolve_toolchains( + repo: &Path, + sprite: &str, + runnable: &[Check], +) -> Result<HashMap<String, String>, String> { + let mut names: Vec<&str> = runnable + .iter() + .flat_map(|check| check.toolchains.iter().map(String::as_str)) + .collect(); + names.sort_unstable(); + names.dedup(); + + let mut dirs = HashMap::new(); + for name in names { + let tree = git_toolchain::resolve(repo, name) + .map_err(|e| format!("could not resolve toolchain {name}: {e}"))?; + sync_toolchain(repo, sprite, tree)?; + dirs.insert(name.to_owned(), format!("{TOOLCHAINS_DIR}/{tree}")); + } + Ok(dirs) +} + +/// Prefix `command` with a `PATH` export activating `toolchains`' extracted +/// directories, declared order first (so the first-listed toolchain's `bin` +/// wins on a name collision); a check with no toolchains is returned +/// unchanged. +fn activate(command: &str, toolchains: &[String], dirs: &HashMap<String, String>) -> String { + if toolchains.is_empty() { + return command.to_owned(); + } + let path = toolchains + .iter() + .filter_map(|name| dirs.get(name)) + .map(|dir| format!("{dir}/bin")) + .collect::<Vec<_>>() + .join(":"); + format!("export PATH={path}:$PATH; {command}") +} + +/// Extract the toolchain tree `tree` into the Sprite at +/// `{TOOLCHAINS_DIR}/<tree>`, once — a directory already there from an +/// earlier push is left alone rather than re-extracted, since the Sprite's +/// persistent filesystem is the cache. Checked before running `git archive` +/// so an already-cached toolchain never streams its (potentially large) +/// contents through a pipe the Sprite has no reason to read. +fn sync_toolchain(repo: &Path, sprite: &str, tree: ObjectId) -> Result<(), String> { + let dir = format!("{TOOLCHAINS_DIR}/{tree}"); + let cached = Command::new("sprite") + .args([ + "exec", + "-s", + sprite, + "--", + "sh", + "-c", + &format!("[ -d {dir} ]"), + ]) + .status() + .map_err(|e| format!("could not run the sprite CLI: {e}"))?; + if cached.success() { + return Ok(()); + } + + let archive = Command::new("git") + .arg("-C") + .arg(repo) + .args(["archive", "--format=tar", &tree.to_string()]) + .output() + .map_err(|e| format!("could not run git archive: {e}"))?; + if !archive.status.success() { + return Err(format!("git archive failed for toolchain {tree}")); + } + + let script = format!("mkdir -p {dir} && tar -x -C {dir}"); + let mut child = Command::new("sprite") + .args(["exec", "-s", sprite, "--", "sh", "-c", &script]) + .stdin(Stdio::piped()) + .spawn() + .map_err(|e| format!("could not run the sprite CLI: {e}"))?; + child + .stdin + .take() + .ok_or("sprite exec did not accept stdin")? + .write_all(&archive.stdout) + .map_err(|e| format!("could not stream the toolchain into the sprite: {e}"))?; + let status = child + .wait() + .map_err(|e| format!("sprite exec did not complete: {e}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("could not extract toolchain {tree} in the sprite")) + } +} + /// How long a single check may run before the worker abandons it. A runaway /// check that outlived this — a hung build, a command blocked on input — is /// killed and recorded `error` rather than wedging the worker (and with it every @@ -746,6 +860,34 @@ assert_eq!(refs, vec!["refs/heads/main", "refs/heads/feature"]); } + #[test] + fn activate_leaves_a_toolchain_free_command_unchanged() { + let dirs = HashMap::new(); + assert_eq!(activate("cargo test", &[], &dirs), "cargo test"); + } + + #[test] + fn activate_prefixes_path_in_declared_order() { + let mut dirs = HashMap::new(); + dirs.insert("gcc".to_owned(), "/toolchains/aaa".to_owned()); + dirs.insert("cmake".to_owned(), "/toolchains/bbb".to_owned()); + let toolchains = vec!["gcc".to_owned(), "cmake".to_owned()]; + assert_eq!( + activate("make", &toolchains, &dirs), + "export PATH=/toolchains/aaa/bin:/toolchains/bbb/bin:$PATH; make" + ); + } + + #[test] + fn activate_skips_a_toolchain_missing_from_dirs() { + let dirs = HashMap::new(); + let toolchains = vec!["gcc".to_owned()]; + assert_eq!( + activate("make", &toolchains, &dirs), + "export PATH=:$PATH; make" + ); + } + #[test] fn composite_status_derives_from_its_dependencies() { assert_eq!(