git-ents.gitmain
⌘K
foforge
docker.rs234 lines · 8.6 KB · rusthistorycomment on this file
1//! The Docker [`Executor`] backend (`effect.execution`, `roots.local`):
2//! shells out to the `docker` CLI (no docker API crate — the same
3//! rationale [`crate::sprite`] uses for the `sprite` CLI), running each
4//! effect in a throwaway `--rm` container with the materialized workdir and
5//! toolchains bind-mounted in.
6//!
7//! Ported from `pre-redo`'s `git-effect::docker` module: the readiness
8//! probe ([`ensure_docker`]) and the pure argv assembly ([`run_args`]) carry
9//! over verbatim (minus the cache-directory bind mount — this design has no
10//! effect-level cache, `model.effect-definition`); the rest is rewritten
11//! against this phase's [`crate::Executor`] trait.
12
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15
16use crate::error::{Error, Result};
17use crate::executor::{
18 Executor, RunOutput, SandboxInputs, activate, parse_exit_marker, wrap_exit_marker,
19};
20
21/// The minimal base image every effect runs in — no toolchain of its own;
22/// everything the command needs comes from the bind-mounted, host-exported
23/// toolchains.
24pub const IMAGE: &str = "debian:stable-slim";
25
26/// Where the workdir is bind-mounted in the container.
27pub const WORKDIR: &str = "/work";
28
29/// Where a toolchain's `bin/` directory is bind-mounted, per toolchain
30/// name: `{TOOLCHAINS_DIR}/<name>/bin`.
31pub const TOOLCHAINS_DIR: &str = "/toolchains";
32
33/// Confirm `docker` is on `PATH` and the daemon answers, with a clean error
34/// — rather than a raw "os error 2" — when it is not. The one place this
35/// backend can fail before an effect ever runs.
36///
37/// # Errors
38///
39/// [`Error::Spawn`] if `docker` could not be started at all;
40/// [`Error::Process`] if it ran but the daemon did not respond.
41pub fn ensure_docker() -> Result<()> {
42 let status = Command::new("docker")
43 .arg("version")
44 .stdout(Stdio::null())
45 .stderr(Stdio::null())
46 .status()
47 .map_err(|e| Error::Spawn {
48 program: "docker".to_owned(),
49 detail: e.to_string(),
50 })?;
51 if status.success() {
52 Ok(())
53 } else {
54 Err(Error::Process {
55 program: "docker".to_owned(),
56 detail: "the daemon did not respond to `docker version`; is it running?".to_owned(),
57 })
58 }
59}
60
61/// Assemble `docker run`'s argv for one sandboxed run — pure, so the exact
62/// invocation is unit tested without a daemon. The command runs under
63/// `sh -c`, stderr folded into stdout so the captured recording is one
64/// interleaved stream, wrapped by [`wrap_exit_marker`] so a completed
65/// command is distinguishable from a docker-side failure (`docker run`
66/// exits 125 when the daemon itself fails — never a result,
67/// `effect.result-taxonomy`).
68///
69/// # Examples
70///
71/// ```
72/// use ents_effect::docker::run_args;
73/// use ents_effect::executor::EXIT_MARKER;
74/// use std::path::Path;
75///
76/// let args = run_args(Path::new("/tmp/s/work"), &[], "cargo test", &[]);
77/// assert!(args.contains(&"/tmp/s/work:/work".to_owned()));
78/// assert!(args.contains(&"debian:stable-slim".to_owned()));
79/// let script = args.last().expect("has a script");
80/// assert!(script.contains("cargo test") && script.contains(EXIT_MARKER));
81/// ```
82#[must_use]
83pub fn run_args(
84 workdir: &Path,
85 toolchains: &[(String, PathBuf)],
86 command: &str,
87 env: &[(String, String)],
88) -> Vec<String> {
89 let mut args = vec![
90 "run".to_owned(),
91 "--rm".to_owned(),
92 "-v".to_owned(),
93 format!("{}:{WORKDIR}", workdir.display()),
94 ];
95 // Passed as literal argv entries, never through a shell — `docker run`
96 // itself sets these on the container's environment, so a secret (a
97 // BYOK credential, `SandboxInputs::env`) needs no shell quoting at all
98 // here (unlike `crate::sprite::SpriteExecutor`, which ships the whole
99 // command as one remote shell-script string).
100 for (var, secret) in env {
101 args.push("-e".to_owned());
102 args.push(format!("{var}={secret}"));
103 }
104 let mut sandbox_dirs = Vec::with_capacity(toolchains.len());
105 for (name, host_dir) in toolchains {
106 let sandbox_dir = format!("{TOOLCHAINS_DIR}/{name}/bin");
107 args.push("-v".to_owned());
108 args.push(format!(
109 "{}:{TOOLCHAINS_DIR}/{name}/bin:ro",
110 host_dir.display()
111 ));
112 sandbox_dirs.push((name.clone(), sandbox_dir));
113 }
114 args.push("-w".to_owned());
115 args.push(WORKDIR.to_owned());
116 args.push(IMAGE.to_owned());
117 args.push("sh".to_owned());
118 args.push("-c".to_owned());
119 args.push(wrap_exit_marker(&activate(command, &sandbox_dirs)));
120 args
121}
122
123/// [`Executor`] running each effect in a throwaway local Docker container
124/// (`roots.local`).
125#[derive(Debug, Clone, Copy, Default)]
126pub struct DockerExecutor;
127
128impl Executor for DockerExecutor {
129 // @relation(effect.result-taxonomy, scope=function)
130 fn run(&self, inputs: &SandboxInputs<'_>) -> Result<RunOutput> {
131 ensure_docker()?;
132 let args = run_args(
133 inputs.workdir,
134 inputs.toolchains,
135 inputs.command,
136 inputs.env,
137 );
138 let output = Command::new("docker")
139 .args(&args)
140 .output()
141 .map_err(|e| Error::Spawn {
142 program: "docker".to_owned(),
143 detail: e.to_string(),
144 })?;
145 let stdout = String::from_utf8_lossy(&output.stdout);
146 // The marker, not docker's exit status, is the completion signal:
147 // `docker run` exits 125 for the daemon's own failures (unpullable
148 // image, bad mount, daemon dying mid-run), which must surface as an
149 // infrastructure error, never as a recorded `fail`
150 // (`effect.result-taxonomy`).
151 parse_exit_marker(&stdout).ok_or_else(|| {
152 Error::Sandbox(format!(
153 "docker run did not complete the command (exit {:?}): {}",
154 output.status.code(),
155 String::from_utf8_lossy(&output.stderr).trim()
156 ))
157 })
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 #![allow(clippy::expect_used, reason = "unit test")]
164
165 use rstest::rstest;
166
167 use super::*;
168
169 #[rstest]
170 // @relation(effect.execution, scope=function, role=Verifies)
171 fn run_args_binds_the_workdir() {
172 let args = run_args(Path::new("/tmp/s/work"), &[], "cargo test", &[]);
173 assert_eq!(
174 args,
175 vec![
176 "run".to_owned(),
177 "--rm".to_owned(),
178 "-v".to_owned(),
179 "/tmp/s/work:/work".to_owned(),
180 "-w".to_owned(),
181 "/work".to_owned(),
182 IMAGE.to_owned(),
183 "sh".to_owned(),
184 "-c".to_owned(),
185 wrap_exit_marker("cargo test"),
186 ]
187 );
188 }
189
190 #[rstest]
191 // @relation(effect.result-taxonomy, scope=function, role=Verifies)
192 fn run_args_script_completes_with_the_exit_marker() {
193 let args = run_args(Path::new("/w"), &[], "true", &[]);
194 let script = args.last().expect("has a script");
195 assert!(
196 script.contains(crate::executor::EXIT_MARKER),
197 "without the marker, a docker-side failure (exit 125) would be \
198 indistinguishable from the command failing"
199 );
200 }
201
202 #[rstest]
203 // @relation(effect.execution, effect.toolchains, scope=function, role=Verifies)
204 fn run_args_binds_each_toolchain_read_only_and_activates_it() {
205 let toolchains = vec![("rust".to_owned(), PathBuf::from("/cache/rust/bin"))];
206 let args = run_args(Path::new("/w"), &toolchains, "cargo test", &[]);
207 assert!(args.contains(&"/cache/rust/bin:/toolchains/rust/bin:ro".to_owned()));
208 let last = args.last().expect("has a command");
209 assert!(last.contains("export PATH=/toolchains/rust/bin:$PATH; cargo test"));
210 }
211
212 #[rstest]
213 // @relation(effect.execution, scope=function, role=Verifies)
214 fn run_args_uses_the_minimal_base_image() {
215 let args = run_args(Path::new("/w"), &[], "true", &[]);
216 assert_eq!(
217 args.get(args.len().saturating_sub(4)).map(String::as_str),
218 Some(IMAGE)
219 );
220 }
221
222 #[rstest]
223 // @relation(roots.config-isolation, scope=function, role=Verifies)
224 fn run_args_passes_env_as_literal_docker_flags_not_shell_text() {
225 let env = vec![("ANTHROPIC_API_KEY".to_owned(), "sk-ant-abc".to_owned())];
226 let args = run_args(Path::new("/w"), &[], "true", &env);
227 assert!(args.contains(&"-e".to_owned()));
228 assert!(args.contains(&"ANTHROPIC_API_KEY=sk-ant-abc".to_owned()));
229 // Never folded into the shell script itself — that's the sprite
230 // backend's own quoting concern, not docker's.
231 let script = args.last().expect("has a script");
232 assert!(!script.contains("sk-ant-abc"));
233 }
234}