Skip to main content

orodruin_cli/
backend.rs

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}