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"; const 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 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}