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!["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}