Add docker_build helper
Task 5 of docs/plans/2026-05-04-per-run-container-lifecycle.md;
unused until Task 8 wires it into Run::execute.

Assisted-by: Claude Opus 4.7 via Claude Code
change pzqnzwlvnkzltvpmmuqsskttmkxpwlnm
commit 5b3362c1e47319eca76cae13bee7f0f52e09b91f
author Alpha Chen <alpha@kejadlen.dev>
date
parent sqoxylst
diff --git a/docs/plans/2026-05-04-per-run-container-lifecycle.md b/docs/plans/2026-05-04-per-run-container-lifecycle.md
index 34520a5..ba1dfb2 100644
--- a/docs/plans/2026-05-04-per-run-container-lifecycle.md
+++ b/docs/plans/2026-05-04-per-run-container-lifecycle.md
@@ -372,7 +372,7 @@ The `sh_honors_cwd` test in `runtime.rs` sets `cwd` explicitly, so no break. Tes
 
 ### Task 5: `docker_build` helper
 
-Implements `docker build -f <workspace>/.quire/Dockerfile -t quire-ci/<repo>:<run-id> <workspace>`.
+Implements `docker build --file <dockerfile> --tag <tag> <context>`. The helper is layout-agnostic: callers compose the dockerfile path themselves. Task 8 (the only caller) passes `workspace.join(".quire/Dockerfile")` as the dockerfile and `workspace` as the build context. Keeping the path policy at the orchestration layer avoids coupling the docker shell-out helper to quire's specific workspace layout.
 
 **Files:**
 - Create: `src/ci/docker.rs` — module for docker shell-out helpers.
@@ -407,20 +407,17 @@ fn docker_build_succeeds_with_minimal_dockerfile() {
         return;
     }
     let dir = tempfile::tempdir().expect("tempdir");
-    let workspace = dir.path();
-    fs_err::create_dir_all(workspace.join(".quire")).expect("mkdir");
-    fs_err::write(
-        workspace.join(".quire/Dockerfile"),
-        "FROM alpine:3.19\nRUN echo built\n",
-    )
-    .expect("write Dockerfile");
+    let context = dir.path();
+    let dockerfile = context.join("Dockerfile");
+    fs_err::write(&dockerfile, "FROM alpine:3.19\nRUN echo built\n")
+        .expect("write Dockerfile");
 
     let tag = "quire-ci/test-task5:test";
-    docker_build(workspace, tag).expect("build should succeed");
+    docker_build(&dockerfile, context, tag).expect("build should succeed");
 
     // Cleanup.
     let _ = std::process::Command::new("docker")
-        .args(["rmi", tag])
+        .args(["image", "rm", tag])
         .output();
 }
 ```
@@ -436,15 +433,14 @@ Expect failure (function doesn't exist).
 **Step 4: Implement**
 
 ```rust
-pub(crate) fn docker_build(workspace: &Path, tag: &str) -> Result<()> {
-    let dockerfile = workspace.join(".quire/Dockerfile");
+pub(crate) fn docker_build(dockerfile: &Path, context: &Path, tag: &str) -> Result<()> {
     let output = std::process::Command::new("docker")
         .arg("build")
-        .arg("-f")
-        .arg(&dockerfile)
-        .arg("-t")
+        .arg("--file")
+        .arg(dockerfile)
+        .arg("--tag")
         .arg(tag)
-        .arg(workspace)
+        .arg(context)
         .output()
         .map_err(|e| Error::ImageBuildFailed { source: e })?;
     if !output.status.success() {
@@ -466,11 +462,12 @@ pub(crate) fn docker_build(workspace: &Path, tag: &str) -> Result<()> {
 fn docker_build_errors_on_bad_dockerfile() {
     if !is_available() { return; }
     let dir = tempfile::tempdir().expect("tempdir");
-    let workspace = dir.path();
-    fs_err::create_dir_all(workspace.join(".quire")).expect("mkdir");
-    fs_err::write(workspace.join(".quire/Dockerfile"), "GARBAGE\n").expect("write");
+    let context = dir.path();
+    let dockerfile = context.join("Dockerfile");
+    fs_err::write(&dockerfile, "GARBAGE\n").expect("write");
 
-    let err = docker_build(workspace, "quire-ci/test-task5-bad:test").expect_err("should fail");
+    let err = docker_build(&dockerfile, context, "quire-ci/test-task5-bad:test")
+        .expect_err("should fail");
     assert!(matches!(err, Error::ImageBuildFailed { .. }));
 }
 ```
@@ -704,7 +701,8 @@ let _container = match executor {
         rec.build_started_at = Some(Timestamp::now());
         self.write_container_record(&rec)?;
 
-        crate::ci::docker::docker_build(workspace, &tag)?;
+        let dockerfile = workspace.join(".quire/Dockerfile");
+        crate::ci::docker::docker_build(&dockerfile, workspace, &tag)?;
         rec.image_tag = Some(tag.clone());
         rec.build_finished_at = Some(Timestamp::now());
         self.write_container_record(&rec)?;
diff --git a/src/ci/docker.rs b/src/ci/docker.rs
new file mode 100644
index 0000000..62f4ffa
--- /dev/null
+++ b/src/ci/docker.rs
@@ -0,0 +1,83 @@
+//! Shell-out helpers for the docker subsystem.
+
+use std::path::Path;
+
+use crate::{Error, Result};
+
+/// Returns true iff the docker daemon is reachable. Used to gate
+/// integration tests; calls `docker info` and treats any failure
+/// (binary missing, daemon down, permissions) as unavailable.
+pub(crate) fn is_available() -> bool {
+    std::process::Command::new("docker")
+        .arg("info")
+        .stdout(std::process::Stdio::null())
+        .stderr(std::process::Stdio::null())
+        .status()
+        .map(|s| s.success())
+        .unwrap_or(false)
+}
+
+/// Build the per-run image. Runs `docker build --file <dockerfile>
+/// --tag <tag> <context>`. Failures (including a missing Dockerfile)
+/// surface as `Error::ImageBuildFailed` carrying docker's stderr.
+pub(crate) fn docker_build(dockerfile: &Path, context: &Path, tag: &str) -> Result<()> {
+    let output = std::process::Command::new("docker")
+        .arg("build")
+        .arg("--file")
+        .arg(dockerfile)
+        .arg("--tag")
+        .arg(tag)
+        .arg(context)
+        .output()
+        .map_err(|e| Error::ImageBuildFailed { source: e })?;
+    if !output.status.success() {
+        return Err(Error::ImageBuildFailed {
+            source: std::io::Error::other(
+                String::from_utf8_lossy(&output.stderr).into_owned(),
+            ),
+        });
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    #[ignore = "requires docker"]
+    fn docker_build_succeeds_with_minimal_dockerfile() {
+        if !is_available() {
+            return;
+        }
+        let dir = tempfile::tempdir().expect("tempdir");
+        let context = dir.path();
+        let dockerfile = context.join("Dockerfile");
+        fs_err::write(&dockerfile, "FROM alpine:3.19\nRUN echo built\n")
+            .expect("write Dockerfile");
+
+        let tag = "quire-ci/test-task5:test";
+        docker_build(&dockerfile, context, tag).expect("build should succeed");
+
+        // Cleanup — best effort; ignore failures.
+        let _ = std::process::Command::new("docker")
+            .args(["image", "rm", tag])
+            .output();
+    }
+
+    #[test]
+    #[ignore = "requires docker"]
+    fn docker_build_errors_on_bad_dockerfile() {
+        if !is_available() {
+            return;
+        }
+        let dir = tempfile::tempdir().expect("tempdir");
+        let context = dir.path();
+        let dockerfile = context.join("Dockerfile");
+        fs_err::write(&dockerfile, "GARBAGE\n").expect("write");
+
+        let err = docker_build(&dockerfile, context, "quire-ci/test-task5-bad:test")
+            .expect_err("should fail");
+        assert!(matches!(err, Error::ImageBuildFailed { .. }));
+    }
+}
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index a637581..a67267a 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -2,6 +2,7 @@
 
 use std::collections::HashMap;
 
+pub(crate) mod docker;
 mod mirror;
 mod pipeline;
 mod registration;