Route sh through docker exec in docker mode
Closes the lifecycle wiring: in docker mode every (sh ...) is wrapped
as `docker exec --interactive --workdir <work_dir> --env KEY=VAL ...
<id> <argv...>` against the per-run container. Host mode is unchanged.
env is forwarded through `--env` flags so the host `docker` process
runs with an empty env — otherwise the caller's vars would leak onto
the host invocation as well as the in-container command.
Assisted-by: Claude Opus 4.7 via Claude Code
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 72f49ac..46c6a92 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -36,9 +36,9 @@ pub enum Executor {
/// effect is: write `container_stopped_at` → drop `session` →
/// `docker stop`.
pub(super) struct DockerLifecycle {
- #[allow(dead_code)] // Held for its Drop; read in Task 9 via Runtime::sh.
- session: crate::ci::docker::ContainerSession,
+ pub(super) session: crate::ci::docker::ContainerSession,
record_path: PathBuf,
+ pub(super) work_dir: String,
}
impl Drop for DockerLifecycle {
@@ -459,9 +459,12 @@ impl Run {
record.build_finished_at = Some(Timestamp::now());
self.write_container_record(&record)?;
- // Start phase.
+ // Start phase. The bind-mount target inside the
+ // container doubles as the working directory for every
+ // `(sh …)` invocation routed through `docker exec`.
+ const WORK_DIR: &str = "/work";
let session =
- crate::ci::docker::ContainerSession::start(&tag, workspace, "/work")?;
+ crate::ci::docker::ContainerSession::start(&tag, workspace, WORK_DIR)?;
record.container_id = Some(session.container_id.clone());
record.container_started_at = Some(session.container_started_at);
@@ -470,6 +473,7 @@ impl Run {
Ok(ExecutorRuntime::Docker(DockerLifecycle {
session,
record_path: self.path().join("container.yml"),
+ work_dir: WORK_DIR.to_string(),
}))
}
}
@@ -1704,22 +1708,32 @@ mod tests {
let run = runs.create(&meta).expect("create");
let run_id = run.id().to_string();
- // Pipeline runs ONE job that does nothing — Task 9 will wire
- // sh through docker exec; this test just confirms the
- // lifecycle works.
+ // Run `uname -s` inside the container. On macOS `uname -s`
+ // returns `Darwin`; getting `Linux` back proves the command
+ // ran inside the alpine container, not on the host.
let pipeline = load(
r#"(local ci (require :quire.ci))
-(ci.job :probe [:quire/push] (fn [_] nil))"#,
+(ci.job :probe [:quire/push] (fn [{: sh}] (sh ["uname" "-s"])))"#,
);
- run.execute(
- pipeline,
- HashMap::new(),
- &src_repo.join(".git"),
- &workspace,
- Executor::Docker,
- )
- .expect("execute");
+ let outputs = run
+ .execute(
+ pipeline,
+ HashMap::new(),
+ &src_repo.join(".git"),
+ &workspace,
+ Executor::Docker,
+ )
+ .expect("execute");
+
+ let probe = &outputs["probe"];
+ assert_eq!(probe.len(), 1);
+ assert_eq!(
+ probe[0].stdout.trim(),
+ "Linux",
+ "expected uname -s to return Linux from inside the container, got: {:?}",
+ probe[0].stdout,
+ );
// Verify container.yml was written with all fields.
let complete = runs.base.join(RunState::Complete.dir_name()).join(&run_id);
diff --git a/src/ci/runtime.rs b/src/ci/runtime.rs
index 6e344b7..597ecae 100644
--- a/src/ci/runtime.rs
+++ b/src/ci/runtime.rs
@@ -18,12 +18,11 @@ use crate::secret::SecretString;
/// The runtime-side carrier for the chosen [`Executor`](super::run::Executor).
/// `Host` runs `sh` directly on the host. `Docker` owns a
-/// [`DockerLifecycle`] whose Drop tears down the per-run container —
-/// the variant's payload is unread until Task 9 routes `sh` through
+/// [`DockerLifecycle`] whose Drop tears down the per-run container;
+/// `Runtime::sh` reads the variant's payload to wrap each command in
/// `docker exec`.
pub(super) enum ExecutorRuntime {
Host,
- #[allow(dead_code)]
Docker(DockerLifecycle),
}
@@ -144,6 +143,15 @@ impl Runtime {
&self.workspace
}
+ /// In docker mode, return the `(container_id, work_dir)` used to
+ /// route `(sh …)` through `docker exec`. Host mode returns `None`.
+ pub(super) fn docker_target(&self) -> Option<(&str, &str)> {
+ match &self.executor {
+ ExecutorRuntime::Host => None,
+ ExecutorRuntime::Docker(l) => Some((&l.session.container_id, &l.work_dir)),
+ }
+ }
+
/// Mark `id` as the currently executing job. `(sh …)` invocations
/// from this job's `run_fn` will record output under `id`, and
/// `(jobs …)` lookups will resolve against `id`'s view.
@@ -183,8 +191,21 @@ impl Runtime {
/// Run `cmd` with `opts` and record its output against the
/// current job (if one is active). Non-zero exits come back in
/// `:exit`, not as `Err`.
+ ///
+ /// In docker mode the command is wrapped in `docker exec` against
+ /// the per-run container; the bind-mounted workspace is the
+ /// container's working directory, and `opts.env` is forwarded as
+ /// `-e KEY=VAL` flags.
pub(super) fn sh(&self, cmd: Cmd, opts: ShOpts) -> crate::Result<ShOutput> {
- let output = cmd.run(opts, &self.workspace)?;
+ let output = match self.docker_target() {
+ None => cmd.run(opts, &self.workspace)?,
+ Some((container_id, work_dir)) => {
+ let wrapped = Cmd::wrap_in_docker_exec(cmd, container_id, work_dir, &opts);
+ // env was embedded into the docker exec argv; clear it
+ // so it isn't also set on the host `docker` process.
+ wrapped.run(ShOpts::default(), &self.workspace)?
+ }
+ };
if let Some(job) = self.current_job.borrow().as_ref() {
self.outputs
.borrow_mut()
@@ -351,6 +372,50 @@ impl Cmd {
// Also revisit the `from_utf8_lossy` calls below — non-UTF-8 bytes
// are silently replaced with U+FFFD and `:stdout` / `:stderr` end
// up as mojibake with no signal that anything was lost.
+ /// Build a new `Cmd` that invokes `docker exec` against the given
+ /// container. Embeds `cwd` as `--workdir` and `opts.env` as
+ /// repeated `-e KEY=VALUE` flags, so the caller's `ShOpts` after
+ /// wrapping should be empty.
+ pub(super) fn wrap_in_docker_exec(
+ inner: Cmd,
+ container_id: &str,
+ work_dir: &str,
+ opts: &ShOpts,
+ ) -> Cmd {
+ // `--interactive` keeps stdin attached. No `--tty` so stdout
+ // and stderr stay as separate streams.
+ let mut args: Vec<String> = vec![
+ "exec".to_string(),
+ "--interactive".to_string(),
+ "--workdir".to_string(),
+ work_dir.to_string(),
+ ];
+ for (k, v) in &opts.env {
+ args.push("--env".to_string());
+ args.push(format!("{k}={v}"));
+ }
+ args.push(container_id.to_string());
+
+ match inner {
+ Cmd::Argv {
+ program,
+ args: inner_args,
+ } => {
+ args.push(program);
+ args.extend(inner_args);
+ }
+ Cmd::Shell(s) => {
+ args.push("sh".to_string());
+ args.push("-c".to_string());
+ args.push(s);
+ }
+ }
+ Cmd::Argv {
+ program: "docker".to_string(),
+ args,
+ }
+ }
+
pub(super) fn run(self, opts: ShOpts, cwd: &std::path::Path) -> std::io::Result<ShOutput> {
let cmd_str = format!("{self}");
let mut command: std::process::Command = self.into();
@@ -605,6 +670,61 @@ mod tests {
);
}
+ #[test]
+ fn cmd_wrap_in_docker_exec_argv_form() {
+ let inner = Cmd::Argv {
+ program: "echo".to_string(),
+ args: vec!["hi".to_string()],
+ };
+ let mut opts = ShOpts::default();
+ opts.env.insert("FOO".to_string(), "bar".to_string());
+
+ let wrapped = Cmd::wrap_in_docker_exec(inner, "abc123", "/work", &opts);
+ let Cmd::Argv { program, args } = wrapped else {
+ panic!("expected argv form");
+ };
+ assert_eq!(program, "docker");
+ assert_eq!(
+ args,
+ vec![
+ "exec",
+ "--interactive",
+ "--workdir",
+ "/work",
+ "--env",
+ "FOO=bar",
+ "abc123",
+ "echo",
+ "hi",
+ ]
+ );
+ }
+
+ #[test]
+ fn cmd_wrap_in_docker_exec_shell_form() {
+ let inner = Cmd::Shell("echo hi | tr a-z A-Z".to_string());
+ let opts = ShOpts::default();
+
+ let wrapped = Cmd::wrap_in_docker_exec(inner, "abc123", "/work", &opts);
+ let Cmd::Argv { program, args } = wrapped else {
+ panic!("expected argv form");
+ };
+ assert_eq!(program, "docker");
+ assert_eq!(
+ args,
+ vec![
+ "exec",
+ "--interactive",
+ "--workdir",
+ "/work",
+ "abc123",
+ "sh",
+ "-c",
+ "echo hi | tr a-z A-Z",
+ ]
+ );
+ }
+
#[test]
fn sh_rejects_number_as_cmd() {
let (runtime, run_fn) = rt(