Skip to main content

greentic_deployer/
desktop.rs

1//! Desktop deploy backend: docker-compose and podman local deploys.
2//!
3//! Pure command construction + thin execution. Integrates with the deploy
4//! extension flow (`src/ext/`) via the `desktop` backend id.
5
6use anyhow::{Context, Result};
7use std::path::PathBuf;
8use std::process::Command;
9
10#[derive(Debug, Clone, serde::Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct DesktopConfig {
13    pub image: Option<String>,
14    pub compose_file: Option<PathBuf>,
15    #[serde(default)]
16    pub ports: Vec<String>,
17    #[serde(default)]
18    pub env: Vec<String>,
19    pub deployment_name: String,
20    #[serde(default = "default_project_dir")]
21    pub project_dir: PathBuf,
22}
23
24fn default_project_dir() -> PathBuf {
25    std::env::temp_dir().join("greentic-desktop")
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum RuntimeKind {
30    DockerCompose,
31    Podman,
32}
33
34impl RuntimeKind {
35    pub fn cmd_name(&self) -> &'static str {
36        match self {
37            Self::DockerCompose => "docker",
38            Self::Podman => "podman",
39        }
40    }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct DesktopPlan {
45    pub runtime: RuntimeKind,
46    pub deployment_name: String,
47    pub compose_file: PathBuf,
48    pub project_dir: PathBuf,
49}
50
51/// Pure transform: config → plan. No IO.
52pub fn plan(runtime: RuntimeKind, config: &DesktopConfig) -> Result<DesktopPlan> {
53    let compose_file = config
54        .compose_file
55        .clone()
56        .unwrap_or_else(|| config.project_dir.join("docker-compose.yml"));
57    Ok(DesktopPlan {
58        runtime,
59        deployment_name: config.deployment_name.clone(),
60        compose_file,
61        project_dir: config.project_dir.clone(),
62    })
63}
64
65pub fn build_up_command(plan: &DesktopPlan) -> Command {
66    // Accepted risk: runtime is a closed enum that resolves only to docker or podman, and no shell is used.
67    // foxguard: ignore[rs/no-command-injection]
68    let mut cmd = Command::new(plan.runtime.cmd_name());
69    match plan.runtime {
70        RuntimeKind::DockerCompose => {
71            cmd.arg("compose")
72                .arg("-p")
73                .arg(&plan.deployment_name)
74                .arg("-f")
75                .arg(&plan.compose_file)
76                .arg("up")
77                .arg("-d");
78        }
79        RuntimeKind::Podman => {
80            cmd.arg("play").arg("kube").arg(&plan.compose_file);
81        }
82    }
83    cmd.current_dir(&plan.project_dir);
84    cmd
85}
86
87pub fn build_down_command(plan: &DesktopPlan) -> Command {
88    // Accepted risk: runtime is a closed enum that resolves only to docker or podman, and no shell is used.
89    // foxguard: ignore[rs/no-command-injection]
90    let mut cmd = Command::new(plan.runtime.cmd_name());
91    match plan.runtime {
92        RuntimeKind::DockerCompose => {
93            cmd.arg("compose")
94                .arg("-p")
95                .arg(&plan.deployment_name)
96                .arg("-f")
97                .arg(&plan.compose_file)
98                .arg("down");
99        }
100        RuntimeKind::Podman => {
101            cmd.arg("pod").arg("stop").arg(&plan.deployment_name);
102        }
103    }
104    cmd
105}
106
107pub fn build_status_command(plan: &DesktopPlan) -> Command {
108    // Accepted risk: runtime is a closed enum that resolves only to docker or podman, and no shell is used.
109    // foxguard: ignore[rs/no-command-injection]
110    let mut cmd = Command::new(plan.runtime.cmd_name());
111    match plan.runtime {
112        RuntimeKind::DockerCompose => {
113            cmd.arg("compose")
114                .arg("-p")
115                .arg(&plan.deployment_name)
116                .arg("ps")
117                .arg("--format")
118                .arg("json");
119        }
120        RuntimeKind::Podman => {
121            cmd.arg("pod")
122                .arg("ps")
123                .arg("--format")
124                .arg("json")
125                .arg("--filter")
126                .arg(format!("name={}", plan.deployment_name));
127        }
128    }
129    cmd
130}
131
132pub fn apply(plan: &DesktopPlan) -> Result<()> {
133    let status = build_up_command(plan)
134        .status()
135        .with_context(|| format!("spawn {}", plan.runtime.cmd_name()))?;
136    if !status.success() {
137        anyhow::bail!(
138            "{} up exited with status {}",
139            plan.runtime.cmd_name(),
140            status
141        );
142    }
143    Ok(())
144}
145
146pub fn destroy(plan: &DesktopPlan) -> Result<()> {
147    let status = build_down_command(plan)
148        .status()
149        .with_context(|| format!("spawn {}", plan.runtime.cmd_name()))?;
150    if !status.success() {
151        anyhow::bail!(
152            "{} down exited with status {}",
153            plan.runtime.cmd_name(),
154            status
155        );
156    }
157    Ok(())
158}
159
160pub fn preflight_check(runtime: RuntimeKind) -> Result<()> {
161    // Accepted risk: runtime is a closed enum that resolves only to docker or podman, and no shell is used.
162    // foxguard: ignore[rs/no-command-injection]
163    let mut cmd = Command::new(runtime.cmd_name());
164    cmd.arg("--version");
165    let out = cmd
166        .output()
167        .with_context(|| format!("'{}' not found in PATH", runtime.cmd_name()))?;
168    if !out.status.success() {
169        anyhow::bail!("'{} --version' returned non-zero", runtime.cmd_name());
170    }
171    Ok(())
172}
173
174/// Abstraction for command execution so tests can stub.
175pub trait CommandRunner: Send + Sync {
176    fn run(&self, cmd: &mut Command) -> anyhow::Result<std::process::ExitStatus>;
177}
178
179/// Production runner: invokes `Command::status()`.
180pub struct RealCommandRunner;
181
182impl CommandRunner for RealCommandRunner {
183    fn run(&self, cmd: &mut Command) -> anyhow::Result<std::process::ExitStatus> {
184        let program = cmd.get_program().to_string_lossy().to_string();
185        cmd.status().with_context(|| format!("spawn {program}"))
186    }
187}
188
189/// Map extension-contributed handler string → `RuntimeKind`.
190pub fn runtime_from_handler(handler: Option<&str>) -> Result<RuntimeKind> {
191    match handler {
192        Some("docker-compose") => Ok(RuntimeKind::DockerCompose),
193        Some("podman") => Ok(RuntimeKind::Podman),
194        Some(other) => Err(anyhow::anyhow!(
195            "unsupported desktop handler: '{other}' (expected 'docker-compose' or 'podman')"
196        )),
197        None => Err(anyhow::anyhow!(
198            "missing handler for desktop backend (expected 'docker-compose' or 'podman')"
199        )),
200    }
201}
202
203/// Extension-driven apply: parse JSON config, dispatch to real runner.
204pub fn apply_from_ext(handler: Option<&str>, config_json: &str, creds_json: &str) -> Result<()> {
205    apply_from_ext_with_runner(handler, config_json, creds_json, &RealCommandRunner)
206}
207
208/// Extension-driven destroy: parse JSON config, dispatch to real runner.
209pub fn destroy_from_ext(handler: Option<&str>, config_json: &str, creds_json: &str) -> Result<()> {
210    destroy_from_ext_with_runner(handler, config_json, creds_json, &RealCommandRunner)
211}
212
213/// Test-friendly apply: accepts an injected runner.
214pub fn apply_from_ext_with_runner(
215    handler: Option<&str>,
216    config_json: &str,
217    _creds_json: &str,
218    runner: &dyn CommandRunner,
219) -> Result<()> {
220    let config: DesktopConfig =
221        serde_json::from_str(config_json).context("parse desktop config JSON")?;
222    let runtime = runtime_from_handler(handler)?;
223    let plan_result = plan(runtime, &config)?;
224    let program_name = plan_result.runtime.cmd_name();
225    let mut cmd = build_up_command(&plan_result);
226    let status = runner.run(&mut cmd)?;
227    if !status.success() {
228        anyhow::bail!("{} up exited with status {}", program_name, status);
229    }
230    Ok(())
231}
232
233/// Test-friendly destroy: accepts an injected runner.
234pub fn destroy_from_ext_with_runner(
235    handler: Option<&str>,
236    config_json: &str,
237    _creds_json: &str,
238    runner: &dyn CommandRunner,
239) -> Result<()> {
240    let config: DesktopConfig =
241        serde_json::from_str(config_json).context("parse desktop config JSON")?;
242    let runtime = runtime_from_handler(handler)?;
243    let plan_result = plan(runtime, &config)?;
244    let program_name = plan_result.runtime.cmd_name();
245    let mut cmd = build_down_command(&plan_result);
246    let status = runner.run(&mut cmd)?;
247    if !status.success() {
248        anyhow::bail!("{} down exited with status {}", program_name, status);
249    }
250    Ok(())
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn sample_config() -> DesktopConfig {
258        DesktopConfig {
259            image: Some("nginx:stable".into()),
260            compose_file: Some(PathBuf::from("/tmp/compose.yml")),
261            ports: vec!["8080:80".into()],
262            env: vec![],
263            deployment_name: "my-app".into(),
264            project_dir: PathBuf::from("/tmp/proj"),
265        }
266    }
267
268    #[test]
269    fn plan_echoes_compose_file_and_name() {
270        let p = plan(RuntimeKind::DockerCompose, &sample_config()).unwrap();
271        assert_eq!(p.deployment_name, "my-app");
272        assert_eq!(p.compose_file, PathBuf::from("/tmp/compose.yml"));
273        assert_eq!(p.runtime, RuntimeKind::DockerCompose);
274    }
275
276    #[test]
277    fn plan_defaults_compose_file_to_project_dir() {
278        let mut cfg = sample_config();
279        cfg.compose_file = None;
280        let p = plan(RuntimeKind::Podman, &cfg).unwrap();
281        assert_eq!(
282            p.compose_file,
283            PathBuf::from("/tmp/proj/docker-compose.yml")
284        );
285    }
286
287    #[test]
288    fn up_command_docker_compose_args() {
289        let p = plan(RuntimeKind::DockerCompose, &sample_config()).unwrap();
290        let cmd = build_up_command(&p);
291        let args: Vec<_> = cmd
292            .get_args()
293            .map(|s| s.to_string_lossy().to_string())
294            .collect();
295        assert_eq!(
296            args,
297            vec![
298                "compose",
299                "-p",
300                "my-app",
301                "-f",
302                "/tmp/compose.yml",
303                "up",
304                "-d"
305            ]
306        );
307        assert_eq!(cmd.get_program(), "docker");
308    }
309
310    #[test]
311    fn up_command_podman_args() {
312        let p = plan(RuntimeKind::Podman, &sample_config()).unwrap();
313        let cmd = build_up_command(&p);
314        let args: Vec<_> = cmd
315            .get_args()
316            .map(|s| s.to_string_lossy().to_string())
317            .collect();
318        assert_eq!(args, vec!["play", "kube", "/tmp/compose.yml"]);
319        assert_eq!(cmd.get_program(), "podman");
320    }
321
322    #[test]
323    fn down_command_docker_compose_args() {
324        let p = plan(RuntimeKind::DockerCompose, &sample_config()).unwrap();
325        let cmd = build_down_command(&p);
326        let args: Vec<_> = cmd
327            .get_args()
328            .map(|s| s.to_string_lossy().to_string())
329            .collect();
330        assert_eq!(
331            args,
332            vec!["compose", "-p", "my-app", "-f", "/tmp/compose.yml", "down"]
333        );
334    }
335
336    #[test]
337    fn status_command_docker_compose_args() {
338        let p = plan(RuntimeKind::DockerCompose, &sample_config()).unwrap();
339        let cmd = build_status_command(&p);
340        let args: Vec<_> = cmd
341            .get_args()
342            .map(|s| s.to_string_lossy().to_string())
343            .collect();
344        assert_eq!(
345            args,
346            vec!["compose", "-p", "my-app", "ps", "--format", "json"]
347        );
348    }
349
350    #[test]
351    fn runtime_from_handler_maps_known_handlers() {
352        assert_eq!(
353            runtime_from_handler(Some("docker-compose")).unwrap(),
354            RuntimeKind::DockerCompose
355        );
356        assert_eq!(
357            runtime_from_handler(Some("podman")).unwrap(),
358            RuntimeKind::Podman
359        );
360    }
361
362    #[test]
363    fn runtime_from_handler_rejects_unknown() {
364        let err = runtime_from_handler(Some("kubernetes")).unwrap_err();
365        assert!(format!("{err}").contains("kubernetes"));
366    }
367
368    #[test]
369    fn runtime_from_handler_rejects_missing() {
370        let err = runtime_from_handler(None).unwrap_err();
371        assert!(format!("{err}").contains("missing handler"));
372    }
373
374    #[derive(Default)]
375    struct RecordingRunner {
376        captured: std::sync::Mutex<Vec<Vec<String>>>,
377    }
378
379    impl CommandRunner for RecordingRunner {
380        fn run(&self, cmd: &mut Command) -> anyhow::Result<std::process::ExitStatus> {
381            let argv: Vec<String> =
382                std::iter::once(cmd.get_program().to_string_lossy().to_string())
383                    .chain(cmd.get_args().map(|a| a.to_string_lossy().to_string()))
384                    .collect();
385            self.captured.lock().unwrap().push(argv);
386            Ok(fake_exit_success())
387        }
388    }
389
390    fn fake_exit_success() -> std::process::ExitStatus {
391        #[cfg(unix)]
392        {
393            use std::os::unix::process::ExitStatusExt;
394            std::process::ExitStatus::from_raw(0)
395        }
396        #[cfg(not(unix))]
397        {
398            use std::os::windows::process::ExitStatusExt;
399            std::process::ExitStatus::from_raw(0)
400        }
401    }
402
403    fn sample_config_json() -> String {
404        r#"{
405            "image": "nginx:stable",
406            "composeFile": "/tmp/compose.yml",
407            "ports": ["8080:80"],
408            "env": [],
409            "deploymentName": "my-app",
410            "projectDir": "/tmp/proj"
411        }"#
412        .to_string()
413    }
414
415    #[test]
416    fn apply_from_ext_with_runner_invokes_up_command() {
417        let runner = RecordingRunner::default();
418        apply_from_ext_with_runner(Some("docker-compose"), &sample_config_json(), "{}", &runner)
419            .expect("apply ok");
420        let captured = runner.captured.lock().unwrap();
421        assert_eq!(captured.len(), 1);
422        let argv = &captured[0];
423        assert_eq!(argv[0], "docker");
424        assert!(argv.contains(&"up".to_string()));
425        assert!(argv.contains(&"my-app".to_string()));
426    }
427
428    #[test]
429    fn destroy_from_ext_with_runner_invokes_down_command() {
430        let runner = RecordingRunner::default();
431        destroy_from_ext_with_runner(Some("docker-compose"), &sample_config_json(), "{}", &runner)
432            .expect("destroy ok");
433        let captured = runner.captured.lock().unwrap();
434        assert_eq!(captured.len(), 1);
435        assert!(captured[0].contains(&"down".to_string()));
436    }
437
438    #[test]
439    fn apply_from_ext_rejects_invalid_json() {
440        let runner = RecordingRunner::default();
441        let err = apply_from_ext_with_runner(Some("docker-compose"), "not json", "{}", &runner)
442            .unwrap_err();
443        assert!(format!("{err}").contains("parse"));
444    }
445
446    #[test]
447    fn apply_from_ext_rejects_unknown_handler() {
448        let runner = RecordingRunner::default();
449        let err =
450            apply_from_ext_with_runner(Some("kubernetes"), &sample_config_json(), "{}", &runner)
451                .unwrap_err();
452        assert!(format!("{err}").contains("kubernetes"));
453    }
454
455    #[test]
456    fn apply_from_ext_propagates_nonzero_exit() {
457        struct FailingRunner;
458        impl CommandRunner for FailingRunner {
459            fn run(&self, _cmd: &mut Command) -> anyhow::Result<std::process::ExitStatus> {
460                #[cfg(unix)]
461                {
462                    use std::os::unix::process::ExitStatusExt;
463                    Ok(std::process::ExitStatus::from_raw(1 << 8))
464                }
465                #[cfg(not(unix))]
466                {
467                    use std::os::windows::process::ExitStatusExt;
468                    Ok(std::process::ExitStatus::from_raw(1))
469                }
470            }
471        }
472        let err = apply_from_ext_with_runner(
473            Some("docker-compose"),
474            &sample_config_json(),
475            "{}",
476            &FailingRunner,
477        )
478        .unwrap_err();
479        assert!(format!("{err}").contains("exited"));
480    }
481}