Skip to main content

opal/executor/
container.rs

1use super::container_arch::default_container_cli_arch;
2use super::core::ExecutorCore;
3use crate::engine::EngineCommandContext;
4use crate::{EngineKind, ExecutorConfig};
5use anyhow::Result;
6use std::process::{Command, Stdio};
7
8const DEFAULT_MEMORY_LIMIT: &str = "1638m"; // ~1.6 GB
9const DEFAULT_CPU_LIMIT: &str = "4";
10
11#[derive(Debug, Clone)]
12pub struct ContainerExecutor {
13    core: ExecutorCore,
14}
15
16impl ContainerExecutor {
17    pub fn new(mut config: ExecutorConfig) -> Result<Self> {
18        config.engine = EngineKind::ContainerCli;
19        let core = ExecutorCore::new(config)?;
20        Ok(Self { core })
21    }
22
23    pub async fn run(&self) -> Result<()> {
24        self.core.run().await
25    }
26
27    pub fn build_command(ctx: &EngineCommandContext<'_>) -> Command {
28        ContainerCommandBuilder::new(ctx)
29            .with_workspace_volume()
30            .with_volumes()
31            .with_network()
32            .with_image_options()
33            .with_env()
34            .build()
35    }
36}
37
38struct ContainerCommandBuilder<'a> {
39    ctx: &'a EngineCommandContext<'a>,
40    command: Command,
41    workspace_mount: String,
42}
43
44impl<'a> ContainerCommandBuilder<'a> {
45    fn new(ctx: &'a EngineCommandContext<'a>) -> Self {
46        let workspace_mount = format!("{}:{}", ctx.workdir.display(), ctx.container_root.display());
47        let cpus = ctx.cpus.unwrap_or(DEFAULT_CPU_LIMIT);
48        let memory = ctx.memory.unwrap_or(DEFAULT_MEMORY_LIMIT);
49        let mut command = Command::new("container");
50        command
51            .stdout(Stdio::piped())
52            .stderr(Stdio::piped())
53            .arg("run")
54            .arg("--name")
55            .arg(ctx.container_name)
56            .arg("--workdir")
57            .arg(ctx.container_root)
58            .arg("--cpus")
59            .arg(cpus)
60            .arg("--memory")
61            .arg(memory);
62        if !ctx.preserve_runtime_objects {
63            command.arg("--rm");
64        }
65        if let Some(arch) = ctx
66            .arch
67            .map(str::to_string)
68            .or_else(|| default_container_cli_arch(ctx.image_platform))
69        {
70            command.arg("--arch").arg(arch);
71        }
72        // TODO: why the fuck is this hardcoded, there should be a default, but it should be a
73        // static and it should be overidable
74        if let Some(dns) = ctx
75            .dns
76            .filter(|value| !value.is_empty())
77            .or(Some("1.1.1.1"))
78        {
79            command.arg("--dns").arg(dns);
80        }
81        Self {
82            ctx,
83            command,
84            workspace_mount,
85        }
86    }
87
88    fn with_volumes(mut self) -> Self {
89        for mount in self.ctx.mounts {
90            self.command.arg("--volume").arg(mount.to_arg());
91        }
92        self
93    }
94
95    fn with_workspace_volume(mut self) -> Self {
96        self.command.arg("--volume").arg(&self.workspace_mount);
97        self
98    }
99
100    fn with_network(mut self) -> Self {
101        if let Some(network) = self.ctx.network {
102            self.command.arg("--network").arg(network);
103        }
104        self
105    }
106
107    fn with_image_options(mut self) -> Self {
108        if let Some(user) = self.ctx.image_user {
109            self.command.arg("--user").arg(user);
110        }
111        if !self.ctx.image_entrypoint.is_empty() {
112            self.command
113                .arg("--entrypoint")
114                .arg(self.ctx.image_entrypoint.join(" "));
115        }
116        self
117    }
118
119    fn with_env(mut self) -> Self {
120        for (key, value) in self.ctx.env_vars {
121            self.command.arg("--env").arg(format!("{key}={value}"));
122        }
123        self
124    }
125
126    fn build(mut self) -> Command {
127        self.command
128            .arg(self.ctx.image)
129            .arg("sh")
130            .arg(self.ctx.container_script);
131        if std::env::var("OPAL_DEBUG_CONTAINER")
132            .map(|v| v == "1")
133            .unwrap_or(false)
134        {
135            let program = self.command.get_program().to_string_lossy();
136            let args: Vec<String> = self
137                .command
138                .get_args()
139                .map(|arg| arg.to_string_lossy().to_string())
140                .collect();
141            eprintln!("[opal] container command: {} {}", program, args.join(" "));
142        }
143        self.command
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::ContainerExecutor;
150    use crate::engine::EngineCommandContext;
151    use crate::executor::container_arch::{container_arch_from_platform, normalize_container_arch};
152    use crate::pipeline::VolumeMount;
153    use std::path::Path;
154
155    #[test]
156    fn build_command_uses_rm_for_job_containers() {
157        let ctx = EngineCommandContext {
158            workdir: Path::new("/workspace"),
159            container_root: Path::new("/builds/workspace"),
160            container_script: Path::new("/opal/script.sh"),
161            container_name: "opal-job",
162            image: "alpine:3.19",
163            image_platform: None,
164            image_user: None,
165            image_entrypoint: &[],
166            mounts: &[],
167            env_vars: &[],
168            network: None,
169            preserve_runtime_objects: false,
170            arch: None,
171            privileged: false,
172            cap_add: &[],
173            cap_drop: &[],
174            cpus: None,
175            memory: None,
176            dns: None,
177        };
178
179        let command = ContainerExecutor::build_command(&ctx);
180        let args: Vec<String> = command
181            .get_args()
182            .map(|arg| arg.to_string_lossy().to_string())
183            .collect();
184
185        assert!(args.iter().any(|arg| arg == "--rm"));
186    }
187
188    #[test]
189    fn build_command_skips_rm_when_preserving_runtime_objects() {
190        let ctx = EngineCommandContext {
191            workdir: Path::new("/workspace"),
192            container_root: Path::new("/builds/workspace"),
193            container_script: Path::new("/opal/script.sh"),
194            container_name: "opal-job",
195            image: "alpine:3.19",
196            image_platform: None,
197            image_user: None,
198            image_entrypoint: &[],
199            mounts: &[],
200            env_vars: &[],
201            network: None,
202            preserve_runtime_objects: true,
203            arch: None,
204            privileged: false,
205            cap_add: &[],
206            cap_drop: &[],
207            cpus: None,
208            memory: None,
209            dns: None,
210        };
211
212        let args: Vec<String> = ContainerExecutor::build_command(&ctx)
213            .get_args()
214            .map(|arg| arg.to_string_lossy().to_string())
215            .collect();
216
217        assert!(!args.iter().any(|arg| arg == "--rm"));
218    }
219
220    #[test]
221    fn build_command_mounts_workspace_before_nested_artifacts() {
222        let mounts = [VolumeMount {
223            host: "/tmp/artifacts".into(),
224            container: "/builds/workspace/tests-temp/shared".into(),
225            read_only: true,
226        }];
227        let ctx = EngineCommandContext {
228            workdir: Path::new("/workspace"),
229            container_root: Path::new("/builds/workspace"),
230            container_script: Path::new("/opal/script.sh"),
231            container_name: "opal-job",
232            image: "alpine:3.19",
233            image_platform: None,
234            image_user: None,
235            image_entrypoint: &[],
236            mounts: &mounts,
237            env_vars: &[],
238            network: None,
239            preserve_runtime_objects: false,
240            arch: None,
241            privileged: false,
242            cap_add: &[],
243            cap_drop: &[],
244            cpus: None,
245            memory: None,
246            dns: None,
247        };
248
249        let args: Vec<String> = ContainerExecutor::build_command(&ctx)
250            .get_args()
251            .map(|arg| arg.to_string_lossy().to_string())
252            .collect();
253        let workspace_mount = "/workspace:/builds/workspace";
254        let artifact_mount = "/tmp/artifacts:/builds/workspace/tests-temp/shared:ro";
255        let workspace_idx = args
256            .iter()
257            .position(|arg| arg == workspace_mount)
258            .expect("workspace mount present");
259        let artifact_idx = args
260            .iter()
261            .position(|arg| arg == artifact_mount)
262            .expect("artifact mount present");
263
264        assert!(workspace_idx < artifact_idx);
265    }
266
267    #[test]
268    fn normalize_container_arch_maps_apple_silicon_name() {
269        assert_eq!(
270            normalize_container_arch("aarch64").as_deref(),
271            Some("arm64")
272        );
273        assert_eq!(
274            normalize_container_arch("x86_64").as_deref(),
275            Some("x86_64")
276        );
277    }
278
279    #[test]
280    fn container_arch_from_platform_maps_common_linux_platforms() {
281        assert_eq!(
282            container_arch_from_platform("linux/arm64/v8").as_deref(),
283            Some("arm64")
284        );
285        assert_eq!(
286            container_arch_from_platform("linux/amd64").as_deref(),
287            Some("x86_64")
288        );
289    }
290
291    #[test]
292    fn build_command_prefers_image_platform_over_host_default() {
293        let ctx = EngineCommandContext {
294            workdir: Path::new("/workspace"),
295            container_root: Path::new("/builds/workspace"),
296            container_script: Path::new("/opal/script.sh"),
297            container_name: "opal-job",
298            image: "alpine:3.19",
299            image_platform: Some("linux/amd64"),
300            image_user: None,
301            image_entrypoint: &[],
302            mounts: &[],
303            env_vars: &[],
304            network: None,
305            preserve_runtime_objects: false,
306            arch: None,
307            privileged: false,
308            cap_add: &[],
309            cap_drop: &[],
310            cpus: None,
311            memory: None,
312            dns: None,
313        };
314
315        let args: Vec<String> = ContainerExecutor::build_command(&ctx)
316            .get_args()
317            .map(|arg| arg.to_string_lossy().to_string())
318            .collect();
319
320        assert!(args.windows(2).any(|pair| pair == ["--arch", "x86_64"]));
321    }
322
323    #[test]
324    fn build_command_includes_image_user_and_entrypoint() {
325        let entrypoint = vec!["/bin/sh".to_string(), "-lc".to_string()];
326        let ctx = EngineCommandContext {
327            workdir: Path::new("/workspace"),
328            container_root: Path::new("/builds/workspace"),
329            container_script: Path::new("/opal/script.sh"),
330            container_name: "opal-job",
331            image: "alpine:3.19",
332            image_platform: None,
333            image_user: Some("1000:1000"),
334            image_entrypoint: &entrypoint,
335            mounts: &[],
336            env_vars: &[],
337            network: None,
338            preserve_runtime_objects: false,
339            arch: None,
340            privileged: false,
341            cap_add: &[],
342            cap_drop: &[],
343            cpus: None,
344            memory: None,
345            dns: None,
346        };
347
348        let args: Vec<String> = ContainerExecutor::build_command(&ctx)
349            .get_args()
350            .map(|arg| arg.to_string_lossy().to_string())
351            .collect();
352
353        assert!(args.windows(2).any(|pair| pair == ["--user", "1000:1000"]));
354        assert!(
355            args.windows(2)
356                .any(|pair| pair == ["--entrypoint", "/bin/sh -lc"])
357        );
358    }
359}