Skip to main content

opal/executor/
docker.rs

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}