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| BackendError::Spawn {
241 step: step.to_string(),
242 command: spec.render(),
243 source,
244 })?;
245 if output.status.success() {
246 return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
247 }
248 Err(BackendError::CommandFailed {
249 step: step.to_string(),
250 command: spec.render(),
251 status: output.status.code(),
252 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
253 })
254 }
255
256 fn run_interactive(&self, step: &str, spec: &CommandSpec) -> Result<(), BackendError> {
257 if self.debug {
258 eprintln!("debug: {}", spec.render());
259 }
260 let status = Command::new(&spec.program)
261 .args(&spec.args)
262 .stdin(Stdio::inherit())
263 .stdout(Stdio::inherit())
264 .stderr(Stdio::inherit())
265 .status()
266 .map_err(|source| BackendError::Spawn {
267 step: step.to_string(),
268 command: spec.render(),
269 source,
270 })?;
271 if status.success() {
272 return Ok(());
273 }
274 Err(BackendError::CommandFailed {
275 step: step.to_string(),
276 command: spec.render(),
277 status: status.code(),
278 stderr: String::new(),
279 })
280 }
281}
282
283impl ContainerBackend for ContainerCliBackend {
284 fn list_all(&self) -> Result<Vec<ContainerSummary>, BackendError> {
285 let output = self.run_captured("list containers", &Self::build_list_spec(self.runtime))?;
286 parse_list_output(&output).map_err(BackendError::from)
287 }
288
289 fn inspect_raw(&self, container_name: &str) -> Result<Option<Value>, BackendError> {
290 let output = self.run_captured(
291 "inspect container",
292 &Self::build_inspect_spec(self.runtime, container_name),
293 )?;
294 Ok(parse_inspect_output(&output))
295 }
296
297 fn system_running(&self) -> Result<bool, BackendError> {
298 if !self.runtime.manages_system_lifecycle() {
299 return Ok(true);
300 }
301
302 match self.run_captured(
303 "check system status",
304 &Self::build_system_status_spec(self.runtime),
305 ) {
306 Ok(output) => Ok(system_status_reports_running(&output)),
307 Err(BackendError::CommandFailed { .. }) => Ok(false),
308 Err(error) => Err(error),
309 }
310 }
311
312 fn start_system(&self) -> Result<(), BackendError> {
313 if !self.runtime.manages_system_lifecycle() {
314 return Ok(());
315 }
316
317 self.run_interactive("start system", &Self::build_system_start_spec(self.runtime))
318 }
319
320 fn build_image(&self, build: &ResolvedBuild) -> Result<(), BackendError> {
321 self.run_interactive("build image", &Self::build_build_spec(self.runtime, build))
322 }
323
324 fn create(&self, environment: &ResolvedEnvironment) -> Result<(), BackendError> {
325 self.run_interactive(
326 "create and start container",
327 &Self::build_create_spec(self.runtime, environment),
328 )
329 }
330
331 fn start(&self, container_name: &str) -> Result<(), BackendError> {
332 self.run_interactive(
333 "start container",
334 &Self::build_start_spec(self.runtime, container_name),
335 )
336 }
337
338 fn exec(&self, container_name: &str, request: &ExecRequest) -> Result<(), BackendError> {
339 self.run_interactive(
340 "exec command",
341 &Self::build_exec_spec(self.runtime, container_name, request),
342 )
343 }
344
345 fn delete(&self, container_name: &str) -> Result<(), BackendError> {
346 self.run_interactive(
347 "delete container",
348 &Self::build_delete_spec(self.runtime, container_name),
349 )
350 }
351
352 fn run_command(&self, step: &str, spec: &CommandSpec) -> Result<(), BackendError> {
353 self.run_interactive(step, spec)
354 }
355}
356
357#[derive(Debug, Error)]
358pub enum BackendError {
359 #[error("failed to spawn `{command}` while attempting to {step}: {source}")]
360 Spawn {
361 step: String,
362 command: String,
363 #[source]
364 source: io::Error,
365 },
366 #[error(
367 "`{command}` failed while attempting to {step}; exit status: {status:?}; stderr: {stderr}"
368 )]
369 CommandFailed {
370 step: String,
371 command: String,
372 status: Option<i32>,
373 stderr: String,
374 },
375 #[error(transparent)]
376 State(#[from] StateError),
377}
378
379impl BackendError {
380 pub fn exit_code(&self) -> i32 {
381 match self {
382 Self::CommandFailed {
383 status: Some(code), ..
384 } => *code,
385 _ => 1,
386 }
387 }
388}
389
390fn append_env_args<'a>(
391 args: &mut Vec<String>,
392 values: impl Iterator<Item = (&'a String, &'a String)>,
393) {
394 for (key, value) in values {
395 args.push("--env".into());
396 args.push(format!("{key}={value}"));
397 }
398}
399
400fn append_mount_args(args: &mut Vec<String>, runtime: ContainerRuntime, mounts: &[ResolvedMount]) {
401 for mount in mounts {
402 args.push("--mount".into());
403 let mut value = format!(
404 "type=bind,source={},target={}",
405 mount.source.display(),
406 mount.target
407 );
408 if matches!(runtime, ContainerRuntime::Podman) {
409 value.push_str(",relabel=shared");
410 }
411 if mount.readonly {
412 value.push_str(",readonly");
413 }
414 args.push(value);
415 }
416}
417
418fn append_user_args(args: &mut Vec<String>, user: Option<&ResolvedUser>) {
419 if let Some(user) = user {
420 args.push("--user".into());
421 args.push(format!("{}:{}", user.uid, user.gid));
422 }
423}
424
425fn shell_quote(value: &str) -> String {
426 if value
427 .chars()
428 .all(|character| character.is_ascii_alphanumeric() || "-_./:=,".contains(character))
429 {
430 return value.to_string();
431 }
432 format!("'{}'", value.replace('\'', "'\\''"))
433}
434
435fn system_status_reports_running(output: &str) -> bool {
436 serde_json::from_str::<Value>(output)
437 .ok()
438 .is_some_and(|value| value_contains_apiserver(&value))
439}
440
441fn value_contains_apiserver(value: &Value) -> bool {
442 match value {
443 Value::Object(map) => {
444 if map.iter().any(|(key, candidate)| {
445 matches!(key.as_str(), "appName" | "component" | "name")
446 && candidate.as_str() == Some("container-apiserver")
447 }) {
448 return true;
449 }
450
451 map.values().any(value_contains_apiserver)
452 }
453 Value::Array(values) => values.iter().any(value_contains_apiserver),
454 Value::String(candidate) => candidate == "container-apiserver",
455 _ => false,
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use std::{collections::BTreeMap, path::PathBuf};
462
463 use super::*;
464
465 fn sample_environment() -> ResolvedEnvironment {
466 ResolvedEnvironment {
467 project_name: "app".into(),
468 environment_name: "dev".into(),
469 container_name: "orodruin-app-123".into(),
470 image: "ubuntu:latest".into(),
471 project_root: PathBuf::from("/tmp/app"),
472 project_mount: "/workspace/app".into(),
473 workdir: "/workspace/app".into(),
474 env: BTreeMap::from([(String::from("LANG"), String::from("C.UTF-8"))]),
475 mounts: vec![
476 ResolvedMount {
477 source: PathBuf::from("/tmp/app"),
478 target: "/workspace/app".into(),
479 readonly: false,
480 },
481 ResolvedMount {
482 source: PathBuf::from("/tmp/cache"),
483 target: "/cache".into(),
484 readonly: true,
485 },
486 ],
487 user: ResolvedUser {
488 username: "dev".into(),
489 uid: 501,
490 gid: 20,
491 home: "/home/dev".into(),
492 },
493 shell: vec!["/bin/bash".into()],
494 startup_command: vec!["sleep".into(), "infinity".into()],
495 default_command: None,
496 build: None,
497 }
498 }
499
500 #[test]
501 fn create_emits_expected_container_command() {
502 let environment = sample_environment();
503 let spec =
504 ContainerCliBackend::build_create_spec(ContainerRuntime::AppleContainer, &environment);
505
506 assert_eq!(spec.program, "container");
507 assert_eq!(spec.args[0], "run");
508 assert_eq!(spec.args[1], "-d");
509 assert!(spec.args.contains(&"--name".to_string()));
510 assert!(
511 spec.args
512 .contains(&"type=bind,source=/tmp/cache,target=/cache,readonly".to_string())
513 );
514 assert!(spec.args.ends_with(&[
515 String::from("ubuntu:latest"),
516 String::from("sleep"),
517 String::from("infinity")
518 ]));
519 }
520
521 #[test]
522 fn exec_targets_container_workdir_and_command() {
523 let spec = ContainerCliBackend::build_exec_spec(
524 ContainerRuntime::AppleContainer,
525 "orodruin-app-123",
526 &ExecRequest {
527 workdir: Some("/workspace/app".into()),
528 env: vec![(String::from("FOO"), String::from("bar"))],
529 command: vec!["cargo".into(), "test".into()],
530 interactive: false,
531 user: Some(ResolvedUser {
532 username: "dev".into(),
533 uid: 501,
534 gid: 20,
535 home: "/home/dev".into(),
536 }),
537 },
538 );
539
540 assert_eq!(spec.args[0], "exec");
541 assert!(spec.args.contains(&"--user".to_string()));
542 assert!(spec.args.contains(&"501:20".to_string()));
543 assert!(spec.args.contains(&"--workdir".to_string()));
544 assert!(spec.args.contains(&"orodruin-app-123".to_string()));
545 assert_eq!(spec.args.last().map(String::as_str), Some("test"));
546 }
547
548 #[test]
549 fn render_wraps_command_context() {
550 let spec = CommandSpec {
551 program: "container".into(),
552 args: vec!["exec".into(), "demo".into(), "echo hello".into()],
553 };
554
555 assert_eq!(spec.render(), "container exec demo 'echo hello'");
556 }
557
558 #[test]
559 fn passthrough_spec_uses_container_binary() {
560 let spec = ContainerCliBackend::build_passthrough_spec(
561 ContainerRuntime::AppleContainer,
562 vec!["image".into(), "pull".into(), "alpine:latest".into()],
563 );
564
565 assert_eq!(spec.program, "container");
566 assert_eq!(spec.args, ["image", "pull", "alpine:latest"]);
567 }
568
569 #[test]
570 fn podman_specs_use_linux_command_shape() {
571 let list_spec = ContainerCliBackend::build_list_spec(ContainerRuntime::Podman);
572 let delete_spec = ContainerCliBackend::build_delete_spec(ContainerRuntime::Podman, "demo");
573 let create_spec =
574 ContainerCliBackend::build_create_spec(ContainerRuntime::Podman, &sample_environment());
575
576 assert_eq!(list_spec.program, "podman");
577 assert_eq!(list_spec.args, ["ps", "--all", "--format", "json"]);
578 assert_eq!(delete_spec.program, "podman");
579 assert_eq!(delete_spec.args, ["rm", "--force", "demo"]);
580 assert!(create_spec.args.windows(2).any(|window| window == ["--userns", "keep-id"]));
581 assert!(create_spec.args.contains(
582 &"type=bind,source=/tmp/app,target=/workspace/app,relabel=shared".to_string()
583 ));
584 assert!(create_spec.args.contains(
585 &"type=bind,source=/tmp/cache,target=/cache,relabel=shared,readonly".to_string()
586 ));
587 }
588}