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)
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),
+ );
+ }
}