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| 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}