1use 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
51pub 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 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 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 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 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
174pub trait CommandRunner: Send + Sync {
176 fn run(&self, cmd: &mut Command) -> anyhow::Result<std::process::ExitStatus>;
177}
178
179pub 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
189pub 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
203pub 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
208pub 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
213pub 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
233pub 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}