feat: extract check toolchains into the sprite and activate them via PATH
commit
b49e500feat: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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!(