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!["ps".into(), "--all".into(), "--format".into(), "json".into()],
103 },
104 }
105 }
106
107 pub fn build_inspect_spec(runtime: ContainerRuntime, container_name: &str) -> CommandSpec {
108 CommandSpec {
109 program: runtime.program().into(),
110 args: vec!["inspect".into(), container_name.into()],
111 }
112 }
113
114 pub fn build_system_status_spec(runtime: ContainerRuntime) -> CommandSpec {
115 CommandSpec {
116 program: runtime.program().into(),
117 args: vec![
118 "system".into(),
119 "status".into(),
120 "--format".into(),
121 "json".into(),
122 ],
123 }
124 }
125
126 pub fn build_system_start_spec(runtime: ContainerRuntime) -> CommandSpec {
127 CommandSpec {
128 program: runtime.program().into(),
129 args: vec!["system".into(), "start".into()],
130 }
131 }
132
133 pub fn build_build_spec(runtime: ContainerRuntime, build: &ResolvedBuild) -> CommandSpec {
134 let mut args = vec!["build".into(), "-t".into(), build.tag.clone()];
135 if let Some(file) = &build.file {
136 args.push("-f".into());
137 args.push(file.display().to_string());
138 }
139 args.push(build.context.display().to_string());
140 CommandSpec {
141 program: runtime.program().into(),
142 args,
143 }
144 }
145
146 pub fn build_create_spec(
147 runtime: ContainerRuntime,
148 environment: &ResolvedEnvironment,
149 ) -> CommandSpec {
150 let mut args = vec![
151 "run".into(),
152 "-d".into(),
153 "--name".into(),
154 environment.container_name.clone(),
155 "--workdir".into(),
156 environment.workdir.clone(),
157 ];
158 append_env_args(&mut args, environment.env.iter());
159 append_mount_args(&mut args, &environment.mounts);
160 args.push(environment.image.clone());
161 args.extend(environment.startup_command.iter().cloned());
162 CommandSpec {
163 program: runtime.program().into(),
164 args,
165 }
166 }
167
168 pub fn build_start_spec(runtime: ContainerRuntime, container_name: &str) -> CommandSpec {
169 CommandSpec {
170 program: runtime.program().into(),
171 args: vec!["start".into(), container_name.into()],
172 }
173 }
174
175 pub fn build_exec_spec(
176 runtime: ContainerRuntime,
177 container_name: &str,
178 request: &ExecRequest,
179 ) -> CommandSpec {
180 let mut args = vec!["exec".into()];
181 if request.interactive {
182 args.push("-i".into());
183 args.push("-t".into());
184 }
185 append_user_args(&mut args, request.user.as_ref());
186 if let Some(workdir) = &request.workdir {
187 args.push("--workdir".into());
188 args.push(workdir.clone());
189 }
190 append_env_args(
191 &mut args,
192 request.env.iter().map(|(key, value)| (key, value)),
193 );
194 args.push(container_name.into());
195 args.extend(request.command.iter().cloned());
196 CommandSpec {
197 program: runtime.program().into(),
198 args,
199 }
200 }
201
202 pub fn build_delete_spec(runtime: ContainerRuntime, container_name: &str) -> CommandSpec {
203 let args = match runtime {
204 ContainerRuntime::AppleContainer => {
205 vec!["delete".into(), "--force".into(), container_name.into()]
206 }
207 ContainerRuntime::Podman => vec!["rm".into(), "--force".into(), container_name.into()],
208 };
209 CommandSpec {
210 program: runtime.program().into(),
211 args,
212 }
213 }
214
215 pub fn build_passthrough_spec(runtime: ContainerRuntime, args: Vec<String>) -> CommandSpec {
216 CommandSpec {
217 program: runtime.program().into(),
218 args,
219 }
220 }
221
222 fn run_captured(&self, step: &str, spec: &CommandSpec) -> Result<String, BackendError> {
223 if self.debug {
224 eprintln!("debug: {}", spec.render());
225 }
226 let output = Command::new(&spec.program)
227 .args(&spec.args)
228 .stdout(Stdio::piped())
229 .stderr(Stdio::piped())
230 .output()
231 .map_err(|source| BackendError::Spawn {
232 step: step.to_string(),
233 command: spec.render(),
234 source,
235 })?;
236 if output.status.success() {
237 return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
238 }
239 Err(BackendError::CommandFailed {
240 step: step.to_string(),
241 command: spec.render(),
242 status: output.status.code(),
243 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
244 })
245 }
246
247 fn run_interactive(&self, step: &str, spec: &CommandSpec) -> Result<(), BackendError> {
248 if self.debug {
249 eprintln!("debug: {}", spec.render());
250 }
251 let status = Command::new(&spec.program)
252 .args(&spec.args)
253 .stdin(Stdio::inherit())
254 .stdout(Stdio::inherit())
255 .stderr(Stdio::inherit())
256 .status()
257 .map_err(|source| BackendError::Spawn {
258 step: step.to_string(),
259 command: spec.render(),
260 source,
261 })?;
262 if status.success() {
263 return Ok(());
264 }
265 Err(BackendError::CommandFailed {
266 step: step.to_string(),
267 command: spec.render(),
268 status: status.code(),
269 stderr: String::new(),
270 })
271 }
272}
273
274impl ContainerBackend for ContainerCliBackend {
275 fn list_all(&self) -> Result<Vec<ContainerSummary>, BackendError> {
276 let output = self.run_captured("list containers", &Self::build_list_spec(self.runtime))?;
277 parse_list_output(&output).map_err(BackendError::from)
278 }
279
280 fn inspect_raw(&self, container_name: &str) -> Result<Option<Value>, BackendError> {
281 let output = self.run_captured(
282 "inspect container",
283 &Self::build_inspect_spec(self.runtime, container_name),
284 )?;
285 Ok(parse_inspect_output(&output))
286 }
287
288 fn system_running(&self) -> Result<bool, BackendError> {
289 if !self.runtime.manages_system_lifecycle() {
290 return Ok(true);
291 }
292
293 match self.run_captured(
294 "check system status",
295 &Self::build_system_status_spec(self.runtime),
296 ) {
297 Ok(output) => Ok(system_status_reports_running(&output)),
298 Err(BackendError::CommandFailed { .. }) => Ok(false),
299 Err(error) => Err(error),
300 }
301 }
302
303 fn start_system(&self) -> Result<(), BackendError> {
304 if !self.runtime.manages_system_lifecycle() {
305 return Ok(());
306 }
307
308 self.run_interactive("start system", &Self::build_system_start_spec(self.runtime))
309 }
310
311 fn build_image(&self, build: &ResolvedBuild) -> Result<(), BackendError> {
312 self.run_interactive("build image", &Self::build_build_spec(self.runtime, build))
313 }
314
315 fn create(&self, environment: &ResolvedEnvironment) -> Result<(), BackendError> {
316 self.run_interactive(
317 "create and start container",
318 &Self::build_create_spec(self.runtime, environment),
319 )
320 }
321
322 fn start(&self, container_name: &str) -> Result<(), BackendError> {
323 self.run_interactive(
324 "start container",
325 &Self::build_start_spec(self.runtime, container_name),
326 )
327 }
328
329 fn exec(&self, container_name: &str, request: &ExecRequest) -> Result<(), BackendError> {
330 self.run_interactive(
331 "exec command",
332 &Self::build_exec_spec(self.runtime, container_name, request),
333 )
334 }
335
336 fn delete(&self, container_name: &str) -> Result<(), BackendError> {
337 self.run_interactive(
338 "delete container",
339 &Self::build_delete_spec(self.runtime, container_name),
340 )
341 }
342
343 fn run_command(&self, step: &str, spec: &CommandSpec) -> Result<(), BackendError> {
344 self.run_interactive(step, spec)
345 }
346}
347
348#[derive(Debug, Error)]
349pub enum BackendError {
350 #[error("failed to spawn `{command}` while attempting to {step}: {source}")]
351 Spawn {
352 step: String,
353 command: String,
354 #[source]
355 source: io::Error,
356 },
357 #[error(
358 "`{command}` failed while attempting to {step}; exit status: {status:?}; stderr: {stderr}"
359 )]
360 CommandFailed {
361 step: String,
362 command: String,
363 status: Option<i32>,
364 stderr: String,
365 },
366 #[error(transparent)]
367 State(#[from] StateError),
368}
369
370impl BackendError {
371 pub fn exit_code(&self) -> i32 {
372 match self {
373 Self::CommandFailed {
374 status: Some(code), ..
375 } => *code,
376 _ => 1,
377 }
378 }
379}
380
381fn append_env_args<'a>(
382 args: &mut Vec<String>,
383 values: impl Iterator<Item = (&'a String, &'a String)>,
384) {
385 for (key, value) in values {
386 args.push("--env".into());
387 args.push(format!("{key}={value}"));
388 }
389}
390
391fn append_mount_args(args: &mut Vec<String>, mounts: &[ResolvedMount]) {
392 for mount in mounts {
393 args.push("--mount".into());
394 let mut value = format!(
395 "type=bind,source={},target={}",
396 mount.source.display(),
397 mount.target
398 );
399 if mount.readonly {
400 value.push_str(",readonly");
401 }
402 args.push(value);
403 }
404}
405
406fn append_user_args(args: &mut Vec<String>, user: Option<&ResolvedUser>) {
407 if let Some(user) = user {
408 args.push("--user".into());
409 args.push(format!("{}:{}", user.uid, user.gid));
410 }
411}
412
413fn shell_quote(value: &str) -> String {
414 if value
415 .chars()
416 .all(|character| character.is_ascii_alphanumeric() || "-_./:=,".contains(character))
417 {
418 return value.to_string();
419 }
420 format!("'{}'", value.replace('\'', "'\\''"))
421}
422
423fn system_status_reports_running(output: &str) -> bool {
424 serde_json::from_str::<Value>(output)
425 .ok()
426 .is_some_and(|value| value_contains_apiserver(&value))
427}
428
429fn value_contains_apiserver(value: &Value) -> bool {
430 match value {
431 Value::Object(map) => {
432 if map.iter().any(|(key, candidate)| {
433 matches!(key.as_str(), "appName" | "component" | "name")
434 && candidate.as_str() == Some("container-apiserver")
435 }) {
436 return true;
437 }
438
439 map.values().any(value_contains_apiserver)
440 }
441 Value::Array(values) => values.iter().any(value_contains_apiserver),
442 Value::String(candidate) => candidate == "container-apiserver",
443 _ => false,
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use std::{collections::BTreeMap, path::PathBuf};
450
451 use super::*;
452
453 fn sample_environment() -> ResolvedEnvironment {
454 ResolvedEnvironment {
455 project_name: "app".into(),
456 environment_name: "dev".into(),
457 container_name: "orodruin-app-123".into(),
458 image: "ubuntu:latest".into(),
459 project_root: PathBuf::from("/tmp/app"),
460 project_mount: "/workspace/app".into(),
461 workdir: "/workspace/app".into(),
462 env: BTreeMap::from([(String::from("LANG"), String::from("C.UTF-8"))]),
463 mounts: vec![
464 ResolvedMount {
465 source: PathBuf::from("/tmp/app"),
466 target: "/workspace/app".into(),
467 readonly: false,
468 },
469 ResolvedMount {
470 source: PathBuf::from("/tmp/cache"),
471 target: "/cache".into(),
472 readonly: true,
473 },
474 ],
475 user: ResolvedUser {
476 username: "dev".into(),
477 uid: 501,
478 gid: 20,
479 home: "/home/dev".into(),
480 },
481 shell: vec!["/bin/bash".into()],
482 startup_command: vec!["sleep".into(), "infinity".into()],
483 default_command: None,
484 build: None,
485 }
486 }
487
488 #[test]
489 fn create_emits_expected_container_command() {
490 let environment = sample_environment();
491 let spec = ContainerCliBackend::build_create_spec(ContainerRuntime::AppleContainer, &environment);
492
493 assert_eq!(spec.program, "container");
494 assert_eq!(spec.args[0], "run");
495 assert_eq!(spec.args[1], "-d");
496 assert!(spec.args.contains(&"--name".to_string()));
497 assert!(
498 spec.args
499 .contains(&"type=bind,source=/tmp/cache,target=/cache,readonly".to_string())
500 );
501 assert!(spec.args.ends_with(&[
502 String::from("ubuntu:latest"),
503 String::from("sleep"),
504 String::from("infinity")
505 ]));
506 }
507
508 #[test]
509 fn exec_targets_container_workdir_and_command() {
510 let spec = ContainerCliBackend::build_exec_spec(
511 ContainerRuntime::AppleContainer,
512 "orodruin-app-123",
513 &ExecRequest {
514 workdir: Some("/workspace/app".into()),
515 env: vec![(String::from("FOO"), String::from("bar"))],
516 command: vec!["cargo".into(), "test".into()],
517 interactive: false,
518 user: Some(ResolvedUser {
519 username: "dev".into(),
520 uid: 501,
521 gid: 20,
522 home: "/home/dev".into(),
523 }),
524 },
525 );
526
527 assert_eq!(spec.args[0], "exec");
528 assert!(spec.args.contains(&"--user".to_string()));
529 assert!(spec.args.contains(&"501:20".to_string()));
530 assert!(spec.args.contains(&"--workdir".to_string()));
531 assert!(spec.args.contains(&"orodruin-app-123".to_string()));
532 assert_eq!(spec.args.last().map(String::as_str), Some("test"));
533 }
534
535 #[test]
536 fn render_wraps_command_context() {
537 let spec = CommandSpec {
538 program: "container".into(),
539 args: vec!["exec".into(), "demo".into(), "echo hello".into()],
540 };
541
542 assert_eq!(spec.render(), "container exec demo 'echo hello'");
543 }
544
545 #[test]
546 fn passthrough_spec_uses_container_binary() {
547 let spec = ContainerCliBackend::build_passthrough_spec(
548 ContainerRuntime::AppleContainer,
549 vec![
550 "image".into(),
551 "pull".into(),
552 "alpine:latest".into(),
553 ]);
554
555 assert_eq!(spec.program, "container");
556 assert_eq!(spec.args, ["image", "pull", "alpine:latest"]);
557 }
558
559 #[test]
560 fn podman_specs_use_linux_command_shape() {
561 let list_spec = ContainerCliBackend::build_list_spec(ContainerRuntime::Podman);
562 let delete_spec =
563 ContainerCliBackend::build_delete_spec(ContainerRuntime::Podman, "demo");
564
565 assert_eq!(list_spec.program, "podman");
566 assert_eq!(list_spec.args, ["ps", "--all", "--format", "json"]);
567 assert_eq!(delete_spec.program, "podman");
568 assert_eq!(delete_spec.args, ["rm", "--force", "demo"]);
569 }
570}