1use super::core::ExecutorCore;
2use crate::engine::EngineCommandContext;
3use crate::{EngineKind, ExecutorConfig};
4use anyhow::Result;
5use std::process::{Command, Stdio};
6
7#[derive(Debug, Clone)]
8pub struct DockerExecutor {
9 core: ExecutorCore,
10}
11
12impl DockerExecutor {
13 pub fn new(mut config: ExecutorConfig) -> Result<Self> {
14 config.engine = EngineKind::Docker;
15 let core = ExecutorCore::new(config)?;
16 Ok(Self { core })
17 }
18
19 pub async fn run(&self) -> Result<()> {
20 self.core.run().await
21 }
22
23 pub fn build_command(ctx: &EngineCommandContext<'_>) -> Command {
24 DockerCommandBuilder::new(ctx)
25 .with_workspace_volume()
26 .with_volumes()
27 .with_network()
28 .with_platform()
29 .with_image_options()
30 .with_privileges()
31 .with_env()
32 .build()
33 }
34}
35
36struct DockerCommandBuilder<'a> {
37 ctx: &'a EngineCommandContext<'a>,
38 command: Command,
39 workspace_mount: String,
40}
41
42impl<'a> DockerCommandBuilder<'a> {
43 fn new(ctx: &'a EngineCommandContext<'a>) -> Self {
44 let workspace_mount = format!("{}:{}", ctx.workdir.display(), ctx.container_root.display());
45 let mut command = Command::new("docker");
46 command
47 .stdout(Stdio::piped())
48 .stderr(Stdio::piped())
49 .arg("run")
50 .arg("--name")
51 .arg(ctx.container_name)
52 .arg("--workdir")
53 .arg(ctx.container_root);
54 Self {
55 ctx,
56 command,
57 workspace_mount,
58 }
59 }
60
61 fn with_volumes(mut self) -> Self {
62 for mount in self.ctx.mounts {
63 self.command.arg("--volume").arg(mount.to_arg());
64 }
65 self
66 }
67
68 fn with_workspace_volume(mut self) -> Self {
69 self.command.arg("--volume").arg(&self.workspace_mount);
70 self
71 }
72
73 fn with_network(mut self) -> Self {
74 if let Some(network) = self.ctx.network {
75 self.command.arg("--network").arg(network);
76 }
77 self
78 }
79
80 fn with_platform(mut self) -> Self {
81 if let Some(platform) = self.ctx.image_platform {
82 self.command.arg("--platform").arg(platform);
83 }
84 self
85 }
86
87 fn with_image_options(mut self) -> Self {
88 if let Some(user) = self.ctx.image_user {
89 self.command.arg("--user").arg(user);
90 }
91 if !self.ctx.image_entrypoint.is_empty() {
92 self.command
93 .arg("--entrypoint")
94 .arg(self.ctx.image_entrypoint.join(" "));
95 }
96 self
97 }
98
99 fn with_privileges(mut self) -> Self {
100 if self.ctx.privileged {
101 self.command.arg("--privileged");
102 }
103 for capability in self.ctx.cap_add {
104 self.command.arg("--cap-add").arg(capability);
105 }
106 for capability in self.ctx.cap_drop {
107 self.command.arg("--cap-drop").arg(capability);
108 }
109 self
110 }
111
112 fn with_env(mut self) -> Self {
113 for (key, value) in self.ctx.env_vars {
114 self.command.arg("--env").arg(format!("{key}={value}"));
115 }
116 self
117 }
118
119 fn build(mut self) -> Command {
120 self.command
121 .arg(self.ctx.image)
122 .arg("sh")
123 .arg(self.ctx.container_script);
124 self.command
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::DockerExecutor;
131 use crate::engine::EngineCommandContext;
132 use crate::pipeline::VolumeMount;
133 use std::path::Path;
134
135 #[test]
136 fn build_command_mounts_workspace_before_nested_artifacts() {
137 let mounts = [VolumeMount {
138 host: "/tmp/artifacts".into(),
139 container: "/builds/workspace/tests-temp/shared".into(),
140 read_only: true,
141 }];
142 let ctx = EngineCommandContext {
143 workdir: Path::new("/workspace"),
144 container_root: Path::new("/builds/workspace"),
145 container_script: Path::new("/opal/script.sh"),
146 container_name: "opal-job",
147 image: "alpine:3.19",
148 image_platform: None,
149 image_user: None,
150 image_entrypoint: &[],
151 mounts: &mounts,
152 env_vars: &[],
153 network: None,
154 preserve_runtime_objects: false,
155 arch: None,
156 privileged: false,
157 cap_add: &[],
158 cap_drop: &[],
159 cpus: None,
160 memory: None,
161 dns: None,
162 };
163
164 let args: Vec<String> = DockerExecutor::build_command(&ctx)
165 .get_args()
166 .map(|arg| arg.to_string_lossy().to_string())
167 .collect();
168 let workspace_mount = "/workspace:/builds/workspace";
169 let artifact_mount = "/tmp/artifacts:/builds/workspace/tests-temp/shared:ro";
170 let workspace_idx = args
171 .iter()
172 .position(|arg| arg == workspace_mount)
173 .expect("workspace mount present");
174 let artifact_idx = args
175 .iter()
176 .position(|arg| arg == artifact_mount)
177 .expect("artifact mount present");
178
179 assert!(workspace_idx < artifact_idx);
180 }
181
182 #[test]
183 fn build_command_includes_platform_when_requested() {
184 let ctx = EngineCommandContext {
185 workdir: Path::new("/workspace"),
186 container_root: Path::new("/builds/workspace"),
187 container_script: Path::new("/opal/script.sh"),
188 container_name: "opal-job",
189 image: "alpine:3.19",
190 image_platform: Some("linux/arm64/v8"),
191 image_user: None,
192 image_entrypoint: &[],
193 mounts: &[],
194 env_vars: &[],
195 network: None,
196 preserve_runtime_objects: false,
197 arch: None,
198 privileged: false,
199 cap_add: &[],
200 cap_drop: &[],
201 cpus: None,
202 memory: None,
203 dns: None,
204 };
205
206 let args: Vec<String> = DockerExecutor::build_command(&ctx)
207 .get_args()
208 .map(|arg| arg.to_string_lossy().to_string())
209 .collect();
210
211 assert!(
212 args.windows(2)
213 .any(|pair| pair == ["--platform", "linux/arm64/v8"])
214 );
215 }
216
217 #[test]
218 fn build_command_includes_privileged_and_capabilities() {
219 let cap_add = vec!["NET_ADMIN".to_string()];
220 let cap_drop = vec!["MKNOD".to_string()];
221 let ctx = EngineCommandContext {
222 workdir: Path::new("/workspace"),
223 container_root: Path::new("/builds/workspace"),
224 container_script: Path::new("/opal/script.sh"),
225 container_name: "opal-job",
226 image: "alpine:3.19",
227 image_platform: None,
228 image_user: None,
229 image_entrypoint: &[],
230 mounts: &[],
231 env_vars: &[],
232 network: None,
233 preserve_runtime_objects: false,
234 arch: None,
235 privileged: true,
236 cap_add: &cap_add,
237 cap_drop: &cap_drop,
238 cpus: None,
239 memory: None,
240 dns: None,
241 };
242
243 let args: Vec<String> = DockerExecutor::build_command(&ctx)
244 .get_args()
245 .map(|arg| arg.to_string_lossy().to_string())
246 .collect();
247
248 assert!(args.iter().any(|arg| arg == "--privileged"));
249 assert!(
250 args.windows(2)
251 .any(|pair| pair == ["--cap-add", "NET_ADMIN"])
252 );
253 assert!(args.windows(2).any(|pair| pair == ["--cap-drop", "MKNOD"]));
254 }
255
256 #[test]
257 fn build_command_includes_image_user_and_entrypoint() {
258 let entrypoint = vec!["/bin/sh".to_string(), "-lc".to_string()];
259 let ctx = EngineCommandContext {
260 workdir: Path::new("/workspace"),
261 container_root: Path::new("/builds/workspace"),
262 container_script: Path::new("/opal/script.sh"),
263 container_name: "opal-job",
264 image: "alpine:3.19",
265 image_platform: None,
266 image_user: Some("1000:1000"),
267 image_entrypoint: &entrypoint,
268 mounts: &[],
269 env_vars: &[],
270 network: None,
271 preserve_runtime_objects: false,
272 arch: None,
273 privileged: false,
274 cap_add: &[],
275 cap_drop: &[],
276 cpus: None,
277 memory: None,
278 dns: None,
279 };
280
281 let args: Vec<String> = DockerExecutor::build_command(&ctx)
282 .get_args()
283 .map(|arg| arg.to_string_lossy().to_string())
284 .collect();
285
286 assert!(args.windows(2).any(|pair| pair == ["--user", "1000:1000"]));
287 assert!(
288 args.windows(2)
289 .any(|pair| pair == ["--entrypoint", "/bin/sh -lc"])
290 );
291 }
292}