Add ContainerSession with RAII teardown
Introduce a struct that owns a running container's identity, started
via `docker run --detach --rm` and stopped via `Drop`. Captures the
container ID, image tag, and start timestamp. The `--rm` flag handles
removal once stop completes, so callers don't need a separate `docker
rm`.

`Drop` swallows errors because it can't return `Result`; failures log
through `tracing::error!` and orphan reconciliation handles anything
that survives.

The `start` constructor is layout-agnostic: callers pass the mount
source and target as parameters rather than baking workspace policy
into the helper. Task 8 will wire this up with `(workspace, "/work")`.

Two gated tests cover ID capture and post-drop cleanup. The struct is
dead code until Task 8.

Assisted-by: Claude Opus 4.7 (1M context)
change otmxtyyxmuxyyqwyttomotyrxzuvwuwq
commit 1b8b3668bd00e39b2aa6a784c29bb445d8042c97
author Alpha Chen <alpha@kejadlen.dev>
date
parent pzqnzwlv
diff --git a/src/ci/docker.rs b/src/ci/docker.rs
index 62f4ffa..23bd38d 100644
--- a/src/ci/docker.rs
+++ b/src/ci/docker.rs
@@ -40,6 +40,83 @@ pub(crate) fn docker_build(dockerfile: &Path, context: &Path, tag: &str) -> Resu
     Ok(())
 }
 
+/// A running container owned by a quire run. Started via [`start`];
+/// stopped via [`Drop`]. The `--rm` flag on `docker run` removes the
+/// container record once stop completes, so callers don't need to
+/// manage `docker rm` separately.
+///
+/// `Drop` swallows errors from `docker stop` because `Drop` cannot
+/// return `Result`. Failures are logged via `tracing::error!`; orphan
+/// reconciliation handles anything that survives.
+pub(crate) struct ContainerSession {
+    pub(crate) container_id: String,
+    pub(crate) image_tag: String,
+    pub(crate) container_started_at: jiff::Timestamp,
+}
+
+impl ContainerSession {
+    /// Start a long-lived container from `image_tag` with `mount_source`
+    /// bind-mounted at `mount_target` inside the container, with
+    /// `mount_target` as the working directory. Captures the container
+    /// ID. Failures surface as `Error::ContainerStartFailed`.
+    pub(crate) fn start(
+        image_tag: &str,
+        mount_source: &Path,
+        mount_target: &str,
+    ) -> Result<Self> {
+        let mount = format!(
+            "type=bind,src={},dst={}",
+            mount_source.to_string_lossy(),
+            mount_target,
+        );
+        let output = std::process::Command::new("docker")
+            .args(["run", "--detach", "--rm", "--mount"])
+            .arg(&mount)
+            // 15-minute ceiling. Real runs end when
+            // `ContainerSession::drop` calls `docker stop`; this is
+            // the safety net for cases where Drop never fires (orphaned
+            // process, killed parent).
+            .args(["--workdir", mount_target, image_tag, "sleep", "15m"])
+            .output()
+            .map_err(|e| Error::ContainerStartFailed { source: e })?;
+        if !output.status.success() {
+            return Err(Error::ContainerStartFailed {
+                source: std::io::Error::other(
+                    String::from_utf8_lossy(&output.stderr).into_owned(),
+                ),
+            });
+        }
+        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
+        Ok(Self {
+            container_id,
+            image_tag: image_tag.to_string(),
+            container_started_at: jiff::Timestamp::now(),
+        })
+    }
+}
+
+impl Drop for ContainerSession {
+    fn drop(&mut self) {
+        let result = std::process::Command::new("docker")
+            .args(["stop", "--time", "5"])
+            .arg(&self.container_id)
+            .output();
+        match result {
+            Ok(out) if out.status.success() => {}
+            Ok(out) => tracing::error!(
+                container_id = %self.container_id,
+                stderr = %String::from_utf8_lossy(&out.stderr),
+                "docker stop returned non-zero"
+            ),
+            Err(e) => tracing::error!(
+                container_id = %self.container_id,
+                error = %e,
+                "docker stop failed"
+            ),
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -80,4 +157,49 @@ mod tests {
             .expect_err("should fail");
         assert!(matches!(err, Error::ImageBuildFailed { .. }));
     }
+
+    #[test]
+    #[ignore = "requires docker"]
+    fn container_session_start_returns_id() {
+        if !is_available() {
+            return;
+        }
+        let dir = tempfile::tempdir().expect("tempdir");
+
+        let session = ContainerSession::start("alpine:3.19", dir.path(), "/work")
+            .expect("start should succeed");
+
+        assert!(!session.container_id.is_empty());
+        // Docker container IDs from `docker run` are 64-char SHA256 hex.
+        assert_eq!(session.container_id.len(), 64, "got: {}", session.container_id);
+        assert_eq!(session.image_tag, "alpine:3.19");
+        // session drops here; docker stop runs.
+    }
+
+    #[test]
+    #[ignore = "requires docker"]
+    fn container_session_drop_stops_container() {
+        if !is_available() {
+            return;
+        }
+        let dir = tempfile::tempdir().expect("tempdir");
+        let id = {
+            let session = ContainerSession::start("alpine:3.19", dir.path(), "/work")
+                .expect("start should succeed");
+            session.container_id.clone()
+        }; // session drops here
+
+        // Give the daemon a moment to settle the stop.
+        std::thread::sleep(std::time::Duration::from_millis(500));
+        let out = std::process::Command::new("docker")
+            .args(["ps", "-a", "--quiet", "--filter"])
+            .arg(format!("id={id}"))
+            .output()
+            .expect("docker ps");
+        assert!(
+            String::from_utf8_lossy(&out.stdout).trim().is_empty(),
+            "container should be removed after drop, but ps shows: {}",
+            String::from_utf8_lossy(&out.stdout),
+        );
+    }
 }