1use std::{
2 io,
3 process::{Command, Stdio},
4};
5
6use serde_json::Value;
7use thiserror::Error;
8
9use crate::{
10 env_model::{ResolvedBuild, ResolvedEnvironment, ResolvedMount, ResolvedUser},
11 state::{ContainerSummary, StateError, parse_inspect_output, parse_list_output},
12};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ContainerRuntime {
16 AppleContainer,
17 Podman,
18}
19
20impl ContainerRuntime {
21 pub fn from_os(os: &str) -> Option<Self> {
22 match os {
23 "macos" => Some(Self::AppleContainer),
24 "linux" => Some(Self::Podman),
25 _ => None,
26 }
27 }
28
29 pub fn program(self) -> &'static str {
30 match self {
31 Self::AppleContainer => "container",
32 Self::Podman => "podman",
33 }
34 }
35
36 pub fn manages_system_lifecycle(self) -> bool {
37 matches!(self, Self::AppleContainer)
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct CommandSpec {
43 pub program: String,
44 pub args: Vec<String>,
45}
46
47impl CommandSpec {
48 pub fn render(&self) -> String {
49 std::iter::once(self.program.as_str())
50 .chain(self.args.iter().map(String::as_str))
51 .map(shell_quote)
52 .collect::<Vec<_>>()
53 .join(" ")
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ExecRequest {
59 pub workdir: Option<String>,
60 pub env: Vec<(String, String)>,
61 pub command: Vec<String>,
62 pub interactive: bool,
63 pub user: Option<ResolvedUser>,
64}
65
66pub trait ContainerBackend {
67 fn list_all(&self) -> Result<Vec<ContainerSummary>, BackendError>;
68 fn inspect_raw(&self, container_name: &str) -> Result<Option<Value>, BackendError>;
69 fn system_running(&self) -> Result<bool, BackendError>;
70 fn start_system(&self) -> Result<(), BackendError>;
71 fn build_image(&self, build: &ResolvedBuild) -> Result<(), BackendError>;
72 fn create(&self, environment: &ResolvedEnvironment) -> Result<(), BackendError>;
73 fn start(&self, container_name: &str) -> Result<(), BackendError>;
74 fn exec(&self, container_name: &str, request: &ExecRequest) -> Result<(), BackendError>;
75 fn delete(&self, container_name: &str) -> Result<(), BackendError>;
76 fn run_command(&self, step: &str, spec: &CommandSpec) -> Result<(), BackendError>;
77}
78
79pub struct ContainerCliBackend {
80 debug: bool,
81 runtime: ContainerRuntime,
82}
83
84impl ContainerCliBackend {
85 pub fn new(debug: bool, runtime: ContainerRuntime) -> Self {
86 Self { debug, runtime }
87 }
88
89 pub fn build_list_spec(runtime: ContainerRuntime) -> CommandSpec {
90 match runtime {
91 ContainerRuntime::AppleContainer => CommandSpec {
92 program: runtime.program().into(),
93 args: vec![
94 "list".into(),
95 "--all".into(),
96 "--format".into(),
97 "json".into(),
98 ],
99 },
100 ContainerRuntime::Podman => CommandSpec {
101 program: runtime.program().into(),
102 args: vec![
103 "ps".into(),
104 "--all".into(),
105 "--format".into(),
106 "json".into(),
107 ],
108 },
109 }
110 }
111
112 pub fn build_inspect_spec(runtime: ContainerRuntime, container_name: &str) -> CommandSpec {
113 CommandSpec {
114 program: runtime.program().into(),
115 args: vec!["inspect".into(), container_name.into()],
116 }
117 }
118
119 pub fn build_system_status_spec(runtime: ContainerRuntime) -> CommandSpec {
120 CommandSpec {
121 program: runtime.program().into(),
122 args: vec![
123 "system".into(),
124 "status".into(),
125 "--format".into(),
126 "json".into(),
127 ],
128 }
129 }
130
131 pub fn build_system_start_spec(runtime: ContainerRuntime) -> CommandSpec {
132 CommandSpec {
133 program: runtime.program().into(),
134 args: vec!["system".into(), "start".into()],
135 }
136 }
137
138 pub fn build_build_spec(runtime: ContainerRuntime, build: &ResolvedBuild) -> CommandSpec {
139 let mut args = vec!["build".into(), "-t".into(), build.tag.clone()];
140 if let Some(file) = &build.file {
141 args.push("-f".into());
142 args.push(file.display().to_string());
143 }
144 args.push(build.context.display().to_string());
145 CommandSpec {
146 program: runtime.program().into(),
147 args,
148 }
149 }
150
151 pub fn build_create_spec(
152 runtime: ContainerRuntime,
153 environment: &ResolvedEnvironment,
154 ) -> CommandSpec {
155 let mut args = vec![
156 "run".into(),
157 "-d".into(),
158 "--name".into(),
159 environment.container_name.clone(),
160 "--workdir".into(),
161 environment.workdir.clone(),
162 ];
163 if matches!(runtime, ContainerRuntime::Podman) {
164 args.push("--userns".into());
165 args.push("keep-id".into());
166 }
167 append_env_args(&mut args, environment.env.iter());
168 append_mount_args(&mut args, runtime, &environment.mounts);
169 args.push(environment.image.clone());
170 args.extend(environment.startup_command.iter().cloned());
171 CommandSpec {
172 program: runtime.program().into(),
173 args,
174 }
175 }
176
177 pub fn build_start_spec(runtime: ContainerRuntime, container_name: &str) -> CommandSpec {
178 CommandSpec {
179 program: runtime.program().into(),
180 args: vec!["start".into(), container_name.into()],
181 }
182 }
183
184 pub fn build_exec_spec(
185 runtime: ContainerRuntime,
186 container_name: &str,
187 request: &ExecRequest,
188 ) -> CommandSpec {
189 let mut args = vec!["exec".into()];
190 if request.interactive {
191 args.push("-i".into());
192 args.push("-t".into());
193 }
194 append_user_args(&mut args, request.user.as_ref());
195 if let Some(workdir) = &request.workdir {
196 args.push("--workdir".into());
197 args.push(workdir.clone());
198 }
199 append_env_args(
200 &mut args,
201 request.env.iter().map(|(key, value)| (key, value)),
202 );
203 args.push(container_name.into());
204 args.extend(request.command.iter().cloned());
205 CommandSpec {
206 program: runtime.program().into(),
207 args,
208 }
209 }
210
211 pub fn build_delete_spec(runtime: ContainerRuntime, container_name: &str) -> CommandSpec {
212 let args = match runtime {
213 ContainerRuntime::AppleContainer => {
214 vec!["delete".into(), "--force".into(), container_name.into()]
215 }
216 ContainerRuntime::Podman => vec!["rm".into(), "--force".into(), container_name.into()],
217 };
218 CommandSpec {
219 program: runtime.program().into(),
220 args,
221 }
222 }
223
224 pub fn build_passthrough_spec(runtime: ContainerRuntime, args: Vec<String>) -> CommandSpec {
225 CommandSpec {
226 program: runtime.program().into(),
227 args,
228 }
229 }
230
231 fn run_captured(&self, step: &str, spec: &CommandSpec) -> Result<String, BackendError> {
232 if self.debug {
233 eprintln!("debug: {}", spec.render());
234 }
235 let output = Command::new(&spec.program)
236 .args(&spec.args)
237 .stdout(Stdio::piped())
238 .stderr(Stdio::piped())
239 .output()
240 .map_err(|source| spawn_error(step, spec, source))?;
241 if output.status.success() {
242 return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
243 }
244 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
245 Err(BackendError::CommandFailed {
246 step: step.to_string(),
247 command: spec.render(),
248 status: output.status.code(),
249 detail: command_failure_detail(&stderr),
250 })
251 }
252
253 fn run_interactive(&self, step: &str, spec: &CommandSpec) -> Result<(), BackendError> {
254 if self.debug {
255 eprintln!("debug: {}", spec.render());
256 }
257 let status = Command::new(&spec.program)
258 .args(&spec.args)
259 .stdin(Stdio::inherit())
260 .stdout(Stdio::inherit())
261 .stderr(Stdio::inherit())
262 .status()
263 .map_err(|source| spawn_error(step, spec, source))?;
264 if status.success() {
265 return Ok(());
266 }
267 Err(BackendError::CommandFailed {
268 step: step.to_string(),
269 command: spec.render(),
270 status: status.code(),
271 detail: command_failure_detail(""),
272 })
273 }
274}
275
276impl ContainerBackend for ContainerCliBackend {
277 fn list_all(&self) -> Result<Vec<ContainerSummary>, BackendError> {
278 let output = self.run_captured("list containers", &Self::build_list_spec(self.runtime))?;
279 parse_list_output(&output).map_err(BackendError::from)
280 }
281
282 fn inspect_raw(&self, container_name: &str) -> Result<Option<Value>, BackendError> {
283 let output = self.run_captured(
284 "inspect container",
285 &Self::build_inspect_spec(self.runtime, container_name),
286 )?;
287 Ok(parse_inspect_output(&output))
288 }
289
290 fn system_running(&self) -> Result<bool, BackendError> {
291 if !self.runtime.manages_system_lifecycle() {
292 return Ok(true);
293 }
294
295 match self.run_captured(
296 "check system status",
297 &Self::build_system_status_spec(self.runtime),
298 ) {
299 Ok(output) => Ok(system_status_reports_running(&output)),
300 Err(BackendError::CommandFailed { .. }) => Ok(false),
301 Err(error) => Err(error),
302 }
303 }
304
305 fn start_system(&self) -> Result<(), BackendError> {
306 if !self.runtime.manages_system_lifecycle() {
307 return Ok(());
308 }
309
310 self.run_interactive("start system", &Self::build_system_start_spec(self.runtime))
311 }
312
313 fn build_image(&self, build: &ResolvedBuild) -> Result<(), BackendError> {
314 self.run_interactive("build image", &Self::build_build_spec(self.runtime, build))
315 }
316
317 fn create(&self, environment: &ResolvedEnvironment) -> Result<(), BackendError> {
318 self.run_interactive(
319 "create and start container",
320 &Self::build_create_spec(self.runtime, environment),
321 )
322 }
323
324 fn start(&self, container_name: &str) -> Result<(), BackendError> {
325 self.run_interactive(
326 "start container",
327 &Self::build_start_spec(self.runtime, container_name),
328 )
329 }
330
331 fn exec(&self, container_name: &str, request: &ExecRequest) -> Result<(), BackendError> {
332 self.run_interactive(
333 "exec command",
334 &Self::build_exec_spec(self.runtime, container_name, request),
335 )
336 }
337
338 fn delete(&self, container_name: &str) -> Result<(), BackendError> {
339 self.run_interactive(
340 "delete container",
341 &Self::build_delete_spec(self.runtime, container_name),
342 )
343 }
344
345 fn run_command(&self, step: &str, spec: &CommandSpec) -> Result<(), BackendError> {
346 self.run_interactive(step, spec)
347 }
348}
349
350#[derive(Debug, Error)]
351pub enum BackendError {
352 #[error("{program} is not installed or not available on PATH")]
353 MissingProgram { program: String },
354 #[error("could not start the container runtime while attempting to {step}: {source}")]
355 Spawn {
356 step: String,
357 #[source]
358 source: io::Error,
359 },
360 #[error("failed to {step}: {detail}")]
361 CommandFailed {
362 step: String,
363 command: String,
364 status: Option<i32>,
365 detail: String,
366 },
367 #[error(transparent)]
368 State(#[from] StateError),
369}
370
371impl BackendError {
372 pub fn exit_code(&self) -> i32 {
373 match self {
374 Self::CommandFailed {
375 status: Some(code), ..
376 } => *code,
377 _ => 1,
378 }
379 }
380}
381
382fn spawn_error(step: &str, spec: &CommandSpec, source: io::Error) -> BackendError {
383 if source.kind() == io::ErrorKind::NotFound {
384 return BackendError::MissingProgram {
385 program: runtime_program_display(&spec.program).to_string(),
386 };
387 }
388
389 BackendError::Spawn {
390 step: step.to_string(),
391 source,
392 }
393}
394
395fn command_failure_detail(stderr: &str) -> String {
396 if stderr.trim().is_empty() {
397 "the container runtime exited unsuccessfully".into()
398 } else {
399 format!("the container runtime reported: {stderr}")
400 }
401}
402
403fn runtime_program_display(program: &str) -> &str {
404 match program {
405 "podman" => "Podman",
406 "container" => "Apple Container CLI",
407 _ => program,
408 }
409}
410
411fn append_env_args<'a>(
412 args: &mut Vec<String>,
413 values: impl Iterator<Item = (&'a String, &'a String)>,
414) {
415 for (key, value) in values {
416 args.push("--env".into());
417 args.push(format!("{key}={value}"));
418 }
419}
420
421fn append_mount_args(args: &mut Vec<String>, runtime: ContainerRuntime, mounts: &[ResolvedMount]) {
422 for mount in mounts {
423 args.push("--mount".into());
424 let mut value = format!(
425 "type=bind,source={},target={}",
426 mount.source.display(),
427 mount.target
428 );
429 if matches!(runtime, ContainerRuntime::Podman) {
430 value.push_str(",relabel=shared");
431 }
432 if mount.readonly {
433 value.push_str(",readonly");
434 }
435 args.push(value);
436 }
437}
438
439fn append_user_args(args: &mut Vec<String>, user: Option<&ResolvedUser>) {
440 if let Some(user) = user {
441 args.push("--user".into());
442 args.push(format!("{}:{}", user.uid, user.gid));
443 }
444}
445
446fn shell_quote(value: &str) -> String {
447 if value
448 .chars()
449 .all(|character| character.is_ascii_alphanumeric() || "-_./:=,".contains(character))
450 {
451 return value.to_string();
452 }
453 format!("'{}'", value.replace('\'', "'\\''"))
454}
455
456fn system_status_reports_running(output: &str) -> bool {
457 serde_json::from_str::<Value>(output)
458 .ok()
459 .is_some_and(|value| value_contains_apiserver(&value))
460}
461
462fn value_contains_apiserver(value: &Value) -> bool {
463 match value {
464 Value::Object(map) => {
465 if map.iter().any(|(key, candidate)| {
466 matches!(key.as_str(), "appName" | "component" | "name")
467 && candidate.as_str() == Some("container-apiserver")
468 }) {
469 return true;
470 }
471
472 map.values().any(value_contains_apiserver)
473 }
474 Value::Array(values) => values.iter().any(value_contains_apiserver),
475 Value::String(candidate) => candidate == "container-apiserver",
476 _ => false,
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use std::{collections::BTreeMap, path::PathBuf};
483
484 use super::*;
485
486 fn sample_environment() -> ResolvedEnvironment {
487 ResolvedEnvironment {
488 project_name: "app".into(),
489 environment_name: "dev".into(),
490 container_name: "orodruin-app-123".into(),
491 image: "ubuntu:latest".into(),
492 project_root: PathBuf::from("/tmp/app"),
493 project_mount: "/workspace/app".into(),
494 workdir: "/workspace/app".into(),
495 env: BTreeMap::from([(String::from("LANG"), String::from("C.UTF-8"))]),
496 mounts: vec![
497 ResolvedMount {
498 source: PathBuf::from("/tmp/app"),
499 target: "/workspace/app".into(),
500 readonly: false,
501 },
502 ResolvedMount {
503 source: PathBuf::from("/tmp/cache"),
504 target: "/cache".into(),
505 readonly: true,
506 },
507 ],
508 user: ResolvedUser {
509 username: "dev".into(),
510 uid: 501,
511 gid: 20,
512 home: "/home/dev".into(),
513 },
514 shell: vec!["/bin/bash".into()],
515 startup_command: vec!["sleep".into(), "infinity".into()],
516 default_command: None,
517 build: None,
518 }
519 }
520
521 #[test]
522 fn create_emits_expected_container_command() {
523 let environment = sample_environment();
524 let spec =
525 ContainerCliBackend::build_create_spec(ContainerRuntime::AppleContainer, &environment);
526
527 assert_eq!(spec.program, "container");
528 assert_eq!(spec.args[0], "run");
529 assert_eq!(spec.args[1], "-d");
530 assert!(spec.args.contains(&"--name".to_string()));
531 assert!(
532 spec.args
533 .contains(&"type=bind,source=/tmp/cache,target=/cache,readonly".to_string())
534 );
535 assert!(spec.args.ends_with(&[
536 String::from("ubuntu:latest"),
537 String::from("sleep"),
538 String::from("infinity")
539 ]));
540 }
541
542 #[test]
543 fn exec_targets_container_workdir_and_command() {
544 let spec = ContainerCliBackend::build_exec_spec(
545 ContainerRuntime::AppleContainer,
546 "orodruin-app-123",
547 &ExecRequest {
548 workdir: Some("/workspace/app".into()),
549 env: vec![(String::from("FOO"), String::from("bar"))],
550 command: vec!["cargo".into(), "test".into()],
551 interactive: false,
552 user: Some(ResolvedUser {
553 username: "dev".into(),
554 uid: 501,
555 gid: 20,
556 home: "/home/dev".into(),
557 }),
558 },
559 );
560
561 assert_eq!(spec.args[0], "exec");
562 assert!(spec.args.contains(&"--user".to_string()));
563 assert!(spec.args.contains(&"501:20".to_string()));
564 assert!(spec.args.contains(&"--workdir".to_string()));
565 assert!(spec.args.contains(&"orodruin-app-123".to_string()));
566 assert_eq!(spec.args.last().map(String::as_str), Some("test"));
567 }
568
569 #[test]
570 fn render_wraps_command_context() {
571 let spec = CommandSpec {
572 program: "container".into(),
573 args: vec!["exec".into(), "demo".into(), "echo hello".into()],
574 };
575
576 assert_eq!(spec.render(), "container exec demo 'echo hello'");
577 }
578
579 #[test]
580 fn passthrough_spec_uses_container_binary() {
581 let spec = ContainerCliBackend::build_passthrough_spec(
582 ContainerRuntime::AppleContainer,
583 vec!["image".into(), "pull".into(), "alpine:latest".into()],
584 );
585
586 assert_eq!(spec.program, "container");
587 assert_eq!(spec.args, ["image", "pull", "alpine:latest"]);
588 }
589
590 #[test]
591 fn podman_specs_use_linux_command_shape() {
592 let list_spec = ContainerCliBackend::build_list_spec(ContainerRuntime::Podman);
593 let delete_spec = ContainerCliBackend::build_delete_spec(ContainerRuntime::Podman, "demo");
594 let create_spec =
595 ContainerCliBackend::build_create_spec(ContainerRuntime::Podman, &sample_environment());
596
597 assert_eq!(list_spec.program, "podman");
598 assert_eq!(list_spec.args, ["ps", "--all", "--format", "json"]);
599 assert_eq!(delete_spec.program, "podman");
600 assert_eq!(delete_spec.args, ["rm", "--force", "demo"]);
601 assert!(
602 create_spec
603 .args
604 .windows(2)
605 .any(|window| window == ["--userns", "keep-id"])
606 );
607 assert!(create_spec.args.contains(
608 &"type=bind,source=/tmp/app,target=/workspace/app,relabel=shared".to_string()
609 ));
610 assert!(create_spec.args.contains(
611 &"type=bind,source=/tmp/cache,target=/cache,relabel=shared,readonly".to_string()
612 ));
613 }
614}