1use std::path::Path;
37
38use serde::Deserialize;
39use serde_json::Value;
40
41use crate::error::CoreError;
42
43use super::RigPlan;
44
45const GATEWAY_HTTP_TARGET: u16 = 8088;
51const GATEWAY_HTTPS_TARGET: u16 = 443;
52
53const STDERR_TAIL_LINES: usize = 5;
56
57#[derive(Debug, Clone, PartialEq)]
61pub struct ComposeOutput {
62 pub stdout: String,
64 pub stderr: String,
66 pub code: i32,
68}
69
70#[async_trait::async_trait]
75pub trait ComposeRunner: Send + Sync {
76 async fn run(&self, args: &[String]) -> ComposeOutput;
78 async fn run_docker(&self, args: &[String]) -> ComposeOutput;
81 async fn run_streaming(
90 &self,
91 args: &[String],
92 line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
93 ) -> ComposeOutput;
94}
95
96pub struct DockerCompose;
99
100impl DockerCompose {
101 async fn spawn(program: &str, args: &[String]) -> ComposeOutput {
105 let joined = std::iter::once(program.to_string())
106 .chain(args.iter().cloned())
107 .collect::<Vec<_>>()
108 .join(" ");
109 match tokio::process::Command::new(program)
110 .args(args)
111 .stdout(std::process::Stdio::piped())
112 .stderr(std::process::Stdio::piped())
113 .output()
114 .await
115 {
116 Ok(output) => ComposeOutput {
117 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
118 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
119 code: output.status.code().unwrap_or(-1),
120 },
121 Err(err) => ComposeOutput {
122 stdout: String::new(),
123 stderr: format!("failed to spawn `{joined}`: {err}"),
124 code: 127,
125 },
126 }
127 }
128
129 async fn spawn_streaming(
135 program: &str,
136 args: &[String],
137 line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
138 ) -> ComposeOutput {
139 use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
140
141 let joined = std::iter::once(program.to_string())
142 .chain(args.iter().cloned())
143 .collect::<Vec<_>>()
144 .join(" ");
145 let mut child = match tokio::process::Command::new(program)
146 .args(args)
147 .stdout(std::process::Stdio::piped())
148 .stderr(std::process::Stdio::piped())
149 .spawn()
150 {
151 Ok(child) => child,
152 Err(err) => {
153 return ComposeOutput {
154 stdout: String::new(),
155 stderr: format!("failed to spawn `{joined}`: {err}"),
156 code: 127,
157 };
158 }
159 };
160 let stdout = child.stdout.take().expect("stdout piped above");
161 let mut stderr = child.stderr.take().expect("stderr piped above");
162 let stderr_task = tokio::spawn(async move {
164 let mut buffer = String::new();
165 let _ = stderr.read_to_string(&mut buffer).await;
166 buffer
167 });
168 let mut lines = BufReader::new(stdout).lines();
169 while let Ok(Some(line)) = lines.next_line().await {
170 line_sink(&line);
171 }
172 match child.wait().await {
173 Ok(status) => ComposeOutput {
174 stdout: String::new(),
175 stderr: stderr_task.await.unwrap_or_default(),
176 code: status.code().unwrap_or(-1),
177 },
178 Err(err) => ComposeOutput {
179 stdout: String::new(),
180 stderr: format!("failed to wait for `{joined}`: {err}"),
181 code: -1,
182 },
183 }
184 }
185}
186
187#[async_trait::async_trait]
188impl ComposeRunner for DockerCompose {
189 async fn run(&self, args: &[String]) -> ComposeOutput {
190 let mut full = vec!["compose".to_string()];
191 full.extend(args.iter().cloned());
192 Self::spawn("docker", &full).await
193 }
194
195 async fn run_docker(&self, args: &[String]) -> ComposeOutput {
196 Self::spawn("docker", args).await
197 }
198
199 async fn run_streaming(
200 &self,
201 args: &[String],
202 line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
203 ) -> ComposeOutput {
204 let mut full = vec!["compose".to_string()];
205 full.extend(args.iter().cloned());
206 Self::spawn_streaming("docker", &full, line_sink).await
207 }
208}
209
210pub fn check_output<'a>(output: &'a ComposeOutput, context: &str) -> Result<&'a str, CoreError> {
214 if output.code == 0 {
215 Ok(&output.stdout)
216 } else {
217 let tail = stderr_tail(&output.stderr);
218 let note = if tail.is_empty() {
219 String::new()
220 } else {
221 format!(": {tail}")
222 };
223 Err(CoreError::Rig(format!(
224 "{context} failed (exit {}){note}",
225 output.code
226 )))
227 }
228}
229
230pub(crate) fn stderr_tail(stderr: &str) -> String {
233 stderr
234 .lines()
235 .rev()
236 .take(STDERR_TAIL_LINES)
237 .collect::<Vec<_>>()
238 .into_iter()
239 .rev()
240 .collect::<Vec<_>>()
241 .join("\n")
242 .trim()
243 .to_string()
244}
245
246pub async fn compose_version(runner: &dyn ComposeRunner) -> Result<String, CoreError> {
251 let output = runner.run(&["version".to_string()]).await;
252 if output.code != 0 {
253 return Err(CoreError::Rig(format!(
254 "docker compose is unavailable (exit {}): {} — install Docker Desktop or \
255 the compose v2 plugin; the legacy docker-compose v1 binary is not supported",
256 output.code,
257 stderr_tail(&output.stderr)
258 )));
259 }
260 parse_compose_version(&output.stdout).ok_or_else(|| {
261 CoreError::Rig(format!(
262 "cannot parse `docker compose version` output ({:?}) — compose ≥ v2 \
263 (the `docker compose` plugin) is required",
264 output.stdout.trim()
265 ))
266 })
267}
268
269fn parse_compose_version(stdout: &str) -> Option<String> {
272 let rest = stdout.trim().strip_prefix("Docker Compose version ")?;
273 let version = rest.split_whitespace().next()?;
274 let version = version.strip_prefix('v').unwrap_or(version);
275 let mut parts = version.split('.');
276 let major: u32 = parts.next()?.parse().ok()?;
277 if major < 2 {
278 return None;
279 }
280 Some(version.to_string())
281}
282
283pub fn config_args(file: &Path, project_dir: &Path) -> Vec<String> {
292 vec![
293 "-f".into(),
294 file.display().to_string(),
295 "--project-directory".into(),
296 project_dir.display().to_string(),
297 "config".into(),
298 "--format".into(),
299 "json".into(),
300 ]
301}
302
303fn project_and_file_args(plan: &RigPlan, override_files: &[std::path::PathBuf]) -> Vec<String> {
314 let mut args = vec![
315 "-p".into(),
316 plan.name.clone(),
317 "-f".into(),
318 plan.compose_file.display().to_string(),
319 ];
320 for override_file in override_files {
321 args.push("-f".into());
322 args.push(override_file.display().to_string());
323 }
324 args
325}
326
327pub fn up_args(
335 plan: &RigPlan,
336 wait_timeout_s: u64,
337 override_files: &[std::path::PathBuf],
338) -> Vec<String> {
339 let mut args = project_and_file_args(plan, override_files);
340 args.extend([
341 "up".into(),
342 "-d".into(),
343 "--wait".into(),
344 "--wait-timeout".into(),
345 wait_timeout_s.to_string(),
346 "--remove-orphans".into(),
347 ]);
348 args
349}
350
351pub fn down_args(
358 plan: &RigPlan,
359 volumes: bool,
360 override_files: &[std::path::PathBuf],
361) -> Vec<String> {
362 let mut args = project_and_file_args(plan, override_files);
363 args.push("down".into());
364 args.push("--remove-orphans".into());
365 if volumes {
366 args.push("-v".into());
367 }
368 args
369}
370
371pub fn ps_args(plan: &RigPlan, override_files: &[std::path::PathBuf]) -> Vec<String> {
376 let mut args = project_and_file_args(plan, override_files);
377 args.push("ps".into());
378 args.push("--format".into());
379 args.push("json".into());
380 args
381}
382
383pub fn logs_args(
390 plan: &RigPlan,
391 tail: u32,
392 follow: bool,
393 service: Option<&str>,
394 override_files: &[std::path::PathBuf],
395) -> Vec<String> {
396 let mut args = project_and_file_args(plan, override_files);
397 args.push("logs".into());
398 args.push("--tail".into());
399 args.push(tail.to_string());
400 if follow {
401 args.push("-f".into());
402 }
403 if let Some(service) = service {
404 args.push(service.to_string());
405 }
406 args
407}
408
409pub fn volume_ls_args(project: &str) -> Vec<String> {
415 vec![
416 "volume".into(),
417 "ls".into(),
418 "--filter".into(),
419 format!("label=com.docker.compose.project={project}"),
420 "--format".into(),
421 "json".into(),
422 ]
423}
424
425pub fn docker_ps_publish_args(port: u16) -> Vec<String> {
429 vec![
430 "ps".into(),
431 "--filter".into(),
432 format!("publish={port}"),
433 "--format".into(),
434 "json".into(),
435 ]
436}
437
438#[derive(Debug, Clone, PartialEq, serde::Serialize)]
445pub struct PortMapping {
446 pub target: u16,
448 pub published: u16,
450}
451
452pub fn parse_config(
462 stdout: &str,
463 compose_file: &Path,
464 project_dir: &Path,
465) -> Result<RigPlan, CoreError> {
466 let trimmed = stdout.trim();
467 let doc: Value = serde_json::from_str(trimmed).map_err(|err| {
468 CoreError::Rig(format!(
469 "cannot parse `docker compose config` output: {err}"
470 ))
471 })?;
472 let root = match doc {
473 Value::Array(mut items) => items.pop().unwrap_or(Value::Null),
474 Value::Object(object) => Value::Object(object),
475 other => other,
476 };
477 let name = root
478 .get("name")
479 .and_then(Value::as_str)
480 .filter(|name| !name.is_empty())
481 .ok_or_else(|| {
482 CoreError::Rig(
483 "resolved compose config carries no `.name` — the project name is the \
484 rig's identity truth; refusing to guess (set COMPOSE_PROJECT_NAME in \
485 the rig's .env)"
486 .to_string(),
487 )
488 })?
489 .to_string();
490
491 let services: Vec<String> = root
492 .get("services")
493 .and_then(Value::as_object)
494 .map(|map| map.keys().cloned().collect())
495 .unwrap_or_default();
496
497 let mut port_mappings: Vec<PortMapping> = Vec::new();
498 let mut gateway_service: Option<String> = None;
502 let mut gateway_service_https: Option<String> = None;
503 if let Some(services) = root.get("services").and_then(Value::as_object) {
504 for (service_name, service) in services {
505 let ports = service.get("ports").and_then(Value::as_array);
506 for port in ports.into_iter().flatten() {
507 if gateway_service.is_none() {
508 let target = port
509 .get("target")
510 .and_then(|value| value.as_u64().and_then(|n| u16::try_from(n).ok()));
511 match target {
512 Some(GATEWAY_HTTP_TARGET) => {
513 gateway_service = Some(service_name.clone());
514 }
515 Some(GATEWAY_HTTPS_TARGET) if gateway_service_https.is_none() => {
516 gateway_service_https = Some(service_name.clone());
517 }
518 _ => {}
519 }
520 }
521 let published = port.get("published").and_then(|value| {
526 value
527 .as_str()
528 .and_then(|s| s.parse().ok())
529 .or_else(|| value.as_u64().and_then(|n| u16::try_from(n).ok()))
530 });
531 let target = port
532 .get("target")
533 .and_then(|value| value.as_u64().and_then(|n| u16::try_from(n).ok()));
534 if let (Some(published), Some(target)) = (published, target)
535 && !port_mappings
536 .iter()
537 .any(|mapping| mapping.published == published)
538 {
539 port_mappings.push(PortMapping { target, published });
540 }
541 }
542 }
543 }
544 let host_ports = port_mappings
545 .iter()
546 .map(|mapping| mapping.published)
547 .collect();
548 let gateway_service = gateway_service.or(gateway_service_https);
549
550 let volumes: Vec<String> = root
551 .get("volumes")
552 .and_then(Value::as_object)
553 .map(|map| map.keys().cloned().collect())
554 .unwrap_or_default();
555
556 Ok(RigPlan {
557 name,
558 config_name: None,
559 compose_file: compose_file.to_path_buf(),
560 project_dir: project_dir.to_path_buf(),
561 services,
562 host_ports,
563 port_mappings,
564 volumes,
565 gateway_service,
566 modules: std::collections::BTreeMap::new(),
567 })
568}
569
570pub async fn reset_preview(
581 runner: &dyn ComposeRunner,
582 plan: &RigPlan,
583) -> Result<Vec<String>, CoreError> {
584 let output = runner.run_docker(&volume_ls_args(&plan.name)).await;
585 let stdout = check_output(&output, "docker volume ls")?;
586 let prefix = format!("{}_", plan.name);
587 Ok(parse_volume_ls_ldjson(stdout)
588 .into_iter()
589 .map(|entry| entry.name)
590 .filter(|name| name.starts_with(&prefix))
591 .collect())
592}
593
594#[derive(Debug, Clone, PartialEq, Deserialize)]
596pub struct Publisher {
597 #[serde(default, rename = "URL")]
599 pub url: Option<String>,
600 #[serde(default, rename = "TargetPort")]
602 pub target_port: Option<u16>,
603 #[serde(default, rename = "PublishedPort")]
605 pub published_port: Option<u16>,
606 #[serde(default, rename = "Protocol")]
608 pub protocol: Option<String>,
609}
610
611#[derive(Debug, Clone, PartialEq, Deserialize)]
615pub struct ServiceStatus {
616 #[serde(default, rename = "Name")]
618 pub name: String,
619 #[serde(default, rename = "Service")]
621 pub service: String,
622 #[serde(default, rename = "State")]
624 pub state: String,
625 #[serde(default, rename = "Health")]
627 pub health: Option<String>,
628 #[serde(default, rename = "ExitCode")]
630 pub exit_code: Option<i64>,
631 #[serde(default, rename = "Publishers")]
633 pub publishers: Vec<Publisher>,
634}
635
636fn parse_ldjson<T: serde::de::DeserializeOwned>(stdout: &str, what: &str) -> Vec<T> {
642 stdout
643 .lines()
644 .filter(|line| !line.trim().is_empty())
645 .filter_map(|line| match serde_json::from_str::<T>(line) {
646 Ok(item) => Some(item),
647 Err(err) => {
648 tracing::warn!(source = what, error = %err, "skipping unparseable line");
649 None
650 }
651 })
652 .collect()
653}
654
655pub fn parse_ps_ldjson(stdout: &str) -> Vec<ServiceStatus> {
658 parse_ldjson(stdout, "docker compose ps")
659}
660
661#[derive(Debug, Clone, PartialEq, Deserialize)]
663pub struct VolumeEntry {
664 #[serde(default, rename = "Name")]
666 pub name: String,
667 #[serde(default, rename = "Labels")]
669 pub labels: Value,
670}
671
672pub fn parse_volume_ls_ldjson(stdout: &str) -> Vec<VolumeEntry> {
674 parse_ldjson(stdout, "docker volume ls")
675}
676
677#[derive(Debug, Clone, PartialEq)]
683pub struct DockerPsEntry {
684 pub name: String,
686 pub compose_project: Option<String>,
688}
689
690pub fn parse_docker_ps_ldjson(stdout: &str) -> Vec<DockerPsEntry> {
693 parse_ldjson::<serde_json::Value>(stdout, "docker ps")
694 .into_iter()
695 .filter_map(|value| {
696 let name = value
699 .get("Names")
700 .or_else(|| value.get("Name"))
701 .and_then(Value::as_str)
702 .map(|names| names.split(',').next().unwrap_or(names).trim().to_string())
703 .unwrap_or_default();
704 if name.is_empty() {
705 return None;
706 }
707 let compose_project = match value.get("Labels") {
708 Some(Value::String(labels)) => labels.split(',').find_map(|pair| {
709 pair.trim()
710 .strip_prefix("com.docker.compose.project=")
711 .map(str::to_string)
712 }),
713 Some(labels @ Value::Object(_)) => labels
714 .get("com.docker.compose.project")
715 .and_then(Value::as_str)
716 .map(str::to_string),
717 _ => None,
718 };
719 Some(DockerPsEntry {
720 name,
721 compose_project,
722 })
723 })
724 .collect()
725}
726
727#[cfg(test)]
728mod tests {
729 use std::path::Path;
730
731 use super::{
732 ComposeOutput, ComposeRunner, DockerCompose, PortMapping, check_output, config_args,
733 docker_ps_publish_args, down_args, logs_args, parse_compose_version, parse_config,
734 parse_docker_ps_ldjson, parse_ps_ldjson, parse_volume_ls_ldjson, ps_args, stderr_tail,
735 up_args, volume_ls_args,
736 };
737 use crate::rig::RigPlan;
738
739 fn sample_plan() -> RigPlan {
740 RigPlan {
741 name: "ignition-devops".into(),
742 config_name: None,
743 compose_file: "/rigs/git-module/docker/docker-compose.yml".into(),
744 project_dir: "/rigs/git-module/docker".into(),
745 services: vec!["ignition".into(), "db".into()],
746 host_ports: vec![9088, 9443],
747 port_mappings: vec![
748 PortMapping {
749 target: 8088,
750 published: 9088,
751 },
752 PortMapping {
753 target: 443,
754 published: 9443,
755 },
756 ],
757 volumes: vec!["gw-data".into()],
758 gateway_service: Some("ignition".into()),
759 modules: std::collections::BTreeMap::new(),
760 }
761 }
762
763 fn s(args: &[&str]) -> Vec<String> {
764 args.iter().map(|a| a.to_string()).collect()
765 }
766
767 #[test]
770 fn config_args_pinned() {
771 assert_eq!(
772 config_args(
773 Path::new("/rigs/docker/compose.yml"),
774 Path::new("/rigs/docker"),
775 ),
776 s(&[
777 "-f",
778 "/rigs/docker/compose.yml",
779 "--project-directory",
780 "/rigs/docker",
781 "config",
782 "--format",
783 "json"
784 ]),
785 );
786 }
787
788 #[test]
789 fn up_args_pinned() {
790 assert_eq!(
791 up_args(&sample_plan(), 300, &[]),
792 s(&[
793 "-p",
794 "ignition-devops",
795 "-f",
796 "/rigs/git-module/docker/docker-compose.yml",
797 "up",
798 "-d",
799 "--wait",
800 "--wait-timeout",
801 "300",
802 "--remove-orphans"
803 ]),
804 );
805 }
806
807 #[test]
811 fn up_args_appends_override_after_base_file() {
812 let override_path =
813 std::path::PathBuf::from("/rigs/git-module/docker/compose.ign-modules.yml");
814 assert_eq!(
815 up_args(&sample_plan(), 300, std::slice::from_ref(&override_path)),
816 s(&[
817 "-p",
818 "ignition-devops",
819 "-f",
820 "/rigs/git-module/docker/docker-compose.yml",
821 "-f",
822 "/rigs/git-module/docker/compose.ign-modules.yml",
823 "up",
824 "-d",
825 "--wait",
826 "--wait-timeout",
827 "300",
828 "--remove-orphans"
829 ]),
830 );
831 }
832
833 #[test]
834 fn down_args_pinned_with_and_without_volumes() {
835 assert_eq!(
836 down_args(&sample_plan(), false, &[]),
837 s(&[
838 "-p",
839 "ignition-devops",
840 "-f",
841 "/rigs/git-module/docker/docker-compose.yml",
842 "down",
843 "--remove-orphans"
844 ]),
845 "plain down keeps volumes (reset's -v arrives in 04-02)"
846 );
847 assert_eq!(
848 down_args(&sample_plan(), true, &[]),
849 s(&[
850 "-p",
851 "ignition-devops",
852 "-f",
853 "/rigs/git-module/docker/docker-compose.yml",
854 "down",
855 "--remove-orphans",
856 "-v"
857 ]),
858 );
859 }
860
861 #[test]
862 fn ps_args_pinned() {
863 assert_eq!(
864 ps_args(&sample_plan(), &[]),
865 s(&[
866 "-p",
867 "ignition-devops",
868 "-f",
869 "/rigs/git-module/docker/docker-compose.yml",
870 "ps",
871 "--format",
872 "json"
873 ]),
874 );
875 }
876
877 #[test]
878 fn logs_args_pinned() {
879 assert_eq!(
880 logs_args(&sample_plan(), 200, false, None, &[]),
881 s(&[
882 "-p",
883 "ignition-devops",
884 "-f",
885 "/rigs/git-module/docker/docker-compose.yml",
886 "logs",
887 "--tail",
888 "200"
889 ]),
890 );
891 assert_eq!(
892 logs_args(&sample_plan(), 50, true, Some("ignition"), &[]),
893 s(&[
894 "-p",
895 "ignition-devops",
896 "-f",
897 "/rigs/git-module/docker/docker-compose.yml",
898 "logs",
899 "--tail",
900 "50",
901 "-f",
902 "ignition"
903 ]),
904 );
905 }
906
907 #[test]
913 fn every_builder_puts_the_override_after_the_base_file() {
914 let override_path =
915 std::path::PathBuf::from("/rigs/git-module/docker/compose.ign-modules.yml");
916 let overrides = std::slice::from_ref(&override_path);
917
918 assert_eq!(
919 down_args(&sample_plan(), false, overrides),
920 s(&[
921 "-p",
922 "ignition-devops",
923 "-f",
924 "/rigs/git-module/docker/docker-compose.yml",
925 "-f",
926 "/rigs/git-module/docker/compose.ign-modules.yml",
927 "down",
928 "--remove-orphans"
929 ]),
930 );
931 assert_eq!(
932 ps_args(&sample_plan(), overrides),
933 s(&[
934 "-p",
935 "ignition-devops",
936 "-f",
937 "/rigs/git-module/docker/docker-compose.yml",
938 "-f",
939 "/rigs/git-module/docker/compose.ign-modules.yml",
940 "ps",
941 "--format",
942 "json"
943 ]),
944 );
945 assert_eq!(
946 logs_args(&sample_plan(), 200, false, None, overrides),
947 s(&[
948 "-p",
949 "ignition-devops",
950 "-f",
951 "/rigs/git-module/docker/docker-compose.yml",
952 "-f",
953 "/rigs/git-module/docker/compose.ign-modules.yml",
954 "logs",
955 "--tail",
956 "200"
957 ]),
958 );
959 assert_eq!(
960 up_args(&sample_plan(), 300, overrides),
961 s(&[
962 "-p",
963 "ignition-devops",
964 "-f",
965 "/rigs/git-module/docker/docker-compose.yml",
966 "-f",
967 "/rigs/git-module/docker/compose.ign-modules.yml",
968 "up",
969 "-d",
970 "--wait",
971 "--wait-timeout",
972 "300",
973 "--remove-orphans"
974 ]),
975 );
976 }
977
978 #[test]
983 fn override_order_is_stable_across_two_modules() {
984 let first = std::path::PathBuf::from("/rigs/git-module/docker/compose.ign-modules-a.yml");
985 let second = std::path::PathBuf::from("/rigs/git-module/docker/compose.ign-modules-b.yml");
986 let overrides = [first.clone(), second.clone()];
987
988 assert_eq!(
989 up_args(&sample_plan(), 300, &overrides),
990 s(&[
991 "-p",
992 "ignition-devops",
993 "-f",
994 "/rigs/git-module/docker/docker-compose.yml",
995 "-f",
996 "/rigs/git-module/docker/compose.ign-modules-a.yml",
997 "-f",
998 "/rigs/git-module/docker/compose.ign-modules-b.yml",
999 "up",
1000 "-d",
1001 "--wait",
1002 "--wait-timeout",
1003 "300",
1004 "--remove-orphans"
1005 ]),
1006 "the two overrides keep the exact order they were passed in"
1007 );
1008
1009 let reversed = [second, first];
1012 assert_eq!(
1013 up_args(&sample_plan(), 300, &reversed),
1014 s(&[
1015 "-p",
1016 "ignition-devops",
1017 "-f",
1018 "/rigs/git-module/docker/docker-compose.yml",
1019 "-f",
1020 "/rigs/git-module/docker/compose.ign-modules-b.yml",
1021 "-f",
1022 "/rigs/git-module/docker/compose.ign-modules-a.yml",
1023 "up",
1024 "-d",
1025 "--wait",
1026 "--wait-timeout",
1027 "300",
1028 "--remove-orphans"
1029 ]),
1030 );
1031 }
1032
1033 #[test]
1039 fn config_args_never_receives_an_override() {
1040 let args = config_args(
1041 Path::new("/rigs/docker/compose.yml"),
1042 Path::new("/rigs/docker"),
1043 );
1044 assert_eq!(
1045 args,
1046 s(&[
1047 "-f",
1048 "/rigs/docker/compose.yml",
1049 "--project-directory",
1050 "/rigs/docker",
1051 "config",
1052 "--format",
1053 "json"
1054 ]),
1055 );
1056 assert_eq!(
1057 args.iter().filter(|arg| arg.as_str() == "-f").count(),
1058 1,
1059 "config_args must carry exactly one -f flag — the base file, never an override"
1060 );
1061 }
1062
1063 #[test]
1064 fn volume_ls_args_pinned_plain_docker_shape() {
1065 assert_eq!(
1066 volume_ls_args("ignition-devops"),
1067 s(&[
1068 "volume",
1069 "ls",
1070 "--filter",
1071 "label=com.docker.compose.project=ignition-devops",
1072 "--format",
1073 "json"
1074 ]),
1075 "plain docker CLI: no compose subcommand, no -p prefix"
1076 );
1077 }
1078
1079 #[test]
1080 fn docker_ps_publish_args_pinned() {
1081 assert_eq!(
1082 docker_ps_publish_args(18088),
1083 s(&["ps", "--filter", "publish=18088", "--format", "json"]),
1084 );
1085 }
1086
1087 #[test]
1090 fn version_parses_live_capture_and_rejects_old_or_garbage() {
1091 assert_eq!(
1093 parse_compose_version("Docker Compose version v5.1.2\n"),
1094 Some("5.1.2".into())
1095 );
1096 assert_eq!(
1097 parse_compose_version("Docker Compose version v2.24.6\n"),
1098 Some("2.24.6".into())
1099 );
1100 assert_eq!(
1102 parse_compose_version("Docker Compose version 2.34.0\n"),
1103 Some("2.34.0".into())
1104 );
1105 assert_eq!(parse_compose_version("1.29.2"), None);
1107 assert_eq!(parse_compose_version(""), None);
1108 assert_eq!(parse_compose_version("docker-compose 1.25.5"), None);
1109 }
1110
1111 #[test]
1114 fn check_output_maps_nonzero_to_rig_with_stderr_tail() {
1115 let ok = ComposeOutput {
1116 stdout: "{}".into(),
1117 stderr: String::new(),
1118 code: 0,
1119 };
1120 assert_eq!(check_output(&ok, "ctx").unwrap(), "{}");
1121
1122 let tail = (1..=9).map(|n| format!("line-{n}")).collect::<Vec<_>>();
1123 let failed = ComposeOutput {
1124 stdout: String::new(),
1125 stderr: tail.join("\n"),
1126 code: 1,
1127 };
1128 let err = check_output(&failed, "docker compose up").unwrap_err();
1129 assert!(matches!(err, crate::error::CoreError::Rig(_)));
1130 let message = err.to_string();
1131 assert!(
1132 message.contains("docker compose up failed (exit 1)"),
1133 "{message}"
1134 );
1135 assert!(
1137 !message.contains("line-4\n"),
1138 "tail keeps at most 5 lines: {message}"
1139 );
1140 assert!(message.contains("line-5"));
1141 assert!(message.contains("line-9"));
1142 }
1143
1144 #[test]
1145 fn stderr_tail_trims_and_keeps_order() {
1146 assert_eq!(stderr_tail("b\na\n"), "b\na");
1147 assert_eq!(stderr_tail(""), "");
1148 assert_eq!(stderr_tail("\n\n"), "");
1149 }
1150
1151 const CONFIG_FIXTURE: &str = r#"{
1157 "name": "whk-global",
1158 "services": {
1159 "ignition": {
1160 "image": "inductiveautomation/ignition:8.3.6",
1161 "ports": [
1162 {"mode": "host", "target": 8088, "published": "9088", "protocol": "tcp"},
1163 {"mode": "host", "target": 443, "published": "9443", "protocol": "tcp"}
1164 ]
1165 },
1166 "git-server": {
1167 "image": "git-server:latest",
1168 "ports": [{"mode": "host", "target": 22, "published": "9022", "protocol": "tcp"}]
1169 }
1170 },
1171 "volumes": {
1172 "gw-data": {"name": "whk-global_gw-data", "driver": "local"},
1173 "gw-tag-definition": {"name": "whk-global_gw-tag-definition"}
1174 },
1175 "networks": {"default": {"name": "whk-global_default"}},
1176 "secrets": {}
1177 }"#;
1178
1179 #[test]
1180 fn parse_config_reads_name_services_ports_volumes() {
1181 let plan = parse_config(
1182 CONFIG_FIXTURE,
1183 Path::new("/whk/whk-environment-orchestration/docker-compose.yml"),
1184 Path::new("/whk/whk-environment-orchestration"),
1185 )
1186 .expect("fixture parses");
1187 assert_eq!(plan.name, "whk-global");
1188 assert_eq!(plan.services, vec!["git-server", "ignition"]);
1189 assert_eq!(plan.host_ports, vec![9022, 9088, 9443]);
1192 assert!(
1193 plan.port_mappings.contains(&PortMapping {
1194 target: 8088,
1195 published: 9088
1196 }),
1197 "string-form `published` tolerated: {:?}",
1198 plan.port_mappings
1199 );
1200 assert_eq!(plan.volumes, vec!["gw-data", "gw-tag-definition"]);
1201 assert_eq!(
1202 plan.compose_file,
1203 Path::new("/whk/whk-environment-orchestration/docker-compose.yml")
1204 );
1205 assert_eq!(
1206 plan.project_dir,
1207 Path::new("/whk/whk-environment-orchestration")
1208 );
1209 }
1210
1211 #[test]
1212 fn parse_config_tolerates_array_doc_and_numeric_published() {
1213 let array_form = format!("[{}]", CONFIG_FIXTURE.replace("\"9088\"", "9088"));
1216 let plan = parse_config(&array_form, Path::new("/c.yml"), Path::new("/"))
1217 .expect("array doc + numeric published parse");
1218 assert_eq!(plan.name, "whk-global");
1219 assert!(plan.host_ports.contains(&9088));
1220 }
1221
1222 #[test]
1223 fn parse_config_without_name_refuses() {
1224 let fixture = r#"{"services": {"ignition": {}}}"#;
1225 let err = parse_config(fixture, Path::new("/c.yml"), Path::new("/")).unwrap_err();
1226 let message = err.to_string();
1227 assert!(message.contains("no `.name`"), "{message}");
1228 assert!(message.contains("COMPOSE_PROJECT_NAME"), "{message}");
1229 }
1230
1231 #[test]
1232 fn parse_config_empty_services_and_missing_sections() {
1233 let plan = parse_config(r#"{"name": "bare"}"#, Path::new("/c.yml"), Path::new("/"))
1234 .expect("minimal doc parses");
1235 assert!(plan.services.is_empty());
1236 assert!(plan.host_ports.is_empty());
1237 assert!(plan.volumes.is_empty());
1238 }
1239
1240 #[tokio::test]
1247 async fn config_resolves_env_project_name_from_project_directory() {
1248 let dir = tempfile::tempdir().expect("tempdir");
1249 let compose = dir.path().join("docker-compose.yml");
1250 std::fs::write(
1251 &compose,
1252 "services:\n sidecar:\n image: alpine:latest\n",
1253 )
1254 .expect("write compose");
1255 std::fs::write(
1256 dir.path().join(".env"),
1257 "COMPOSE_PROJECT_NAME=env-resolved-name\n",
1258 )
1259 .expect("write .env");
1260
1261 let runner = DockerCompose;
1262 if runner.run(&["version".to_string()]).await.code != 0 {
1263 eprintln!("skipping: docker compose unavailable");
1264 return;
1265 }
1266 let output = runner.run(&config_args(&compose, dir.path())).await;
1269 assert_eq!(output.code, 0, "config run: {}", output.stderr);
1270 let plan = parse_config(&output.stdout, &compose, dir.path()).expect("resolve parses");
1271 assert_eq!(
1272 plan.name, "env-resolved-name",
1273 "the .env name wins over the directory-derived default"
1274 );
1275 }
1276
1277 #[tokio::test]
1283 async fn run_streaming_forwards_lines_via_piped_stdout() {
1284 let dir = tempfile::tempdir().expect("tempdir");
1285 let compose = dir.path().join("docker-compose.yml");
1286 std::fs::write(
1287 &compose,
1288 "services:\n sidecar:\n image: alpine:latest\n",
1289 )
1290 .expect("write compose");
1291
1292 let runner = DockerCompose;
1293 if runner.run(&["version".to_string()]).await.code != 0 {
1294 eprintln!("skipping: docker compose unavailable");
1295 return;
1296 }
1297 if runner.run_docker(&["version".to_string()]).await.code != 0 {
1304 eprintln!("skipping: docker daemon unreachable");
1305 return;
1306 }
1307 let plan = parse_config(
1308 r#"{"name":"stream-fixture","services":{"sidecar":{"image":"alpine"}}}"#,
1309 &compose,
1310 dir.path(),
1311 )
1312 .expect("plan parses");
1313 let mut streamed = 0usize;
1314 let mut sink = |_: &str| streamed += 1;
1315 let output = runner
1316 .run_streaming(&logs_args(&plan, 5, false, None, &[]), &mut sink)
1317 .await;
1318 assert_eq!(
1319 output.code, 0,
1320 "compose logs on an absent project: {}",
1321 output.stderr
1322 );
1323 assert_eq!(
1324 streamed, 0,
1325 "no containers → no lines, but the spawn/read/wait ran"
1326 );
1327 assert!(
1328 output.stdout.is_empty(),
1329 "streamed stdout never reports back"
1330 );
1331 }
1332
1333 const PS_ROW_FULL: &str = r#"{"Command":"\"/usr/bin/start-ignition.sh\"","CreatedAt":"2026-08-22 10:00:00 +0000 UTC","ExitCode":0,"Health":"healthy","Labels":{"com.docker.compose.project":"whk-global","com.docker.compose.service":"ignition"},"Name":"whk-global-ignition-1","Networks":"whk-global_default","Ports":"0.0.0.0:9088->8088/tcp, 0.0.0.0:9443->443/tcp","Publishers":[{"URL":"0.0.0.0","TargetPort":8088,"PublishedPort":9088,"Protocol":"tcp"},{"URL":"0.0.0.0","TargetPort":443,"PublishedPort":9443,"Protocol":"tcp"}],"RunningFor":"2 hours","Service":"ignition","State":"running","Status":"Up 2 hours (healthy)"}"#;
1338
1339 const PS_ROW_MINIMAL: &str = r#"{"Command":"sleep","ExitCode":0,"Name":"whk-global-sidecar-1","Service":"sidecar","State":"exited","Status":"Exited (0) 3 minutes ago"}"#;
1342
1343 #[test]
1344 fn parse_ps_ldjson_reads_rows_and_tolerates_missing_optionals() {
1345 let rows = parse_ps_ldjson(&format!("{PS_ROW_FULL}\n{PS_ROW_MINIMAL}\n"));
1346 assert_eq!(rows.len(), 2);
1347 assert_eq!(rows[0].name, "whk-global-ignition-1");
1348 assert_eq!(rows[0].service, "ignition");
1349 assert_eq!(rows[0].state, "running");
1350 assert_eq!(rows[0].health.as_deref(), Some("healthy"));
1351 assert_eq!(rows[0].exit_code, Some(0));
1352 assert_eq!(rows[0].publishers.len(), 2);
1353 assert_eq!(rows[0].publishers[0].target_port, Some(8088));
1354 assert_eq!(rows[0].publishers[0].published_port, Some(9088));
1355 assert_eq!(rows[0].publishers[0].protocol.as_deref(), Some("tcp"));
1356
1357 assert_eq!(rows[1].health, None);
1358 assert!(rows[1].publishers.is_empty());
1359 }
1360
1361 #[test]
1362 fn parse_ps_ldjson_empty_and_warning_lines() {
1363 assert!(parse_ps_ldjson("").is_empty(), "a down rig has no rows");
1364 let rows = parse_ps_ldjson(&format!("[NOTE] something\n{PS_ROW_MINIMAL}\n"));
1366 assert_eq!(rows.len(), 1);
1367 }
1368
1369 #[test]
1370 fn parse_volume_ls_ldjson_fixture() {
1371 let stdout = concat!(
1372 r#"{"CreatedAt":"2026-08-22T10:00:00Z","Driver":"local","Labels":{"com.docker.compose.project":"ignition-devops","com.docker.compose.volume":"gw-data"},"Mountpoint":"/var/lib/docker/volumes/ignition-devops_gw-data/_data","Name":"ignition-devops_gw-data","Options":null,"Scope":"local"}"#,
1373 "\n",
1374 r#"{"CreatedAt":"2026-08-22T10:00:00Z","Driver":"local","Labels":{"com.docker.compose.project":"ignition-devops","com.docker.compose.volume":"gw-tag-definition"},"Mountpoint":"/var/lib/docker/volumes/ignition-devops_gw-tag-definition/_data","Name":"ignition-devops_gw-tag-definition","Options":null,"Scope":"local"}"#,
1375 "\n",
1376 );
1377 let volumes = parse_volume_ls_ldjson(stdout);
1378 assert_eq!(volumes.len(), 2);
1379 assert_eq!(volumes[0].name, "ignition-devops_gw-data");
1380 assert_eq!(volumes[1].name, "ignition-devops_gw-tag-definition");
1381 assert!(parse_volume_ls_ldjson("").is_empty());
1382 }
1383
1384 #[test]
1385 fn parse_docker_ps_labels_as_string_and_map() {
1386 let string_labels = r#"{"ID":"abc123","Image":"alpine","Labels":"com.docker.compose.project=other-project,com.docker.compose.service=sidecar","Names":"other-project-sidecar-1","Ports":"0.0.0.0:18088->8088/tcp","State":"running"}"#;
1388 let rows = parse_docker_ps_ldjson(string_labels);
1389 assert_eq!(rows.len(), 1);
1390 assert_eq!(rows[0].name, "other-project-sidecar-1");
1391 assert_eq!(rows[0].compose_project.as_deref(), Some("other-project"));
1392
1393 let map_labels =
1395 r#"{"Name":"ign-research","Labels":{"com.docker.compose.project":"research"}}"#;
1396 let rows = parse_docker_ps_ldjson(map_labels);
1397 assert_eq!(rows[0].name, "ign-research");
1398 assert_eq!(rows[0].compose_project.as_deref(), Some("research"));
1399
1400 let rows = parse_docker_ps_ldjson(r#"{"Names":"standalone-thing","Labels":""}"#);
1402 assert_eq!(rows[0].compose_project, None);
1403 assert!(parse_docker_ps_ldjson("").is_empty());
1404 }
1405}