1use std::{collections::HashMap, path::PathBuf};
2
3use async_trait::async_trait;
4use serde::{Deserialize, Deserializer, Serialize, de};
5use util::command::Command;
6
7use crate::{
8 command_json::evaluate_json_command, devcontainer_api::DevContainerError,
9 devcontainer_json::MountDefinition,
10};
11
12#[derive(Debug, Deserialize, Serialize, Eq, PartialEq)]
13#[serde(rename_all = "PascalCase")]
14pub(crate) struct DockerPs {
15 #[serde(alias = "ID")]
16 pub(crate) id: String,
17}
18
19#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
20#[serde(rename_all = "PascalCase")]
21pub(crate) struct DockerState {
22 pub(crate) running: bool,
23}
24
25#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
26#[serde(rename_all = "PascalCase")]
27pub(crate) struct DockerInspect {
28 pub(crate) id: String,
29 pub(crate) config: DockerInspectConfig,
30 pub(crate) mounts: Option<Vec<DockerInspectMount>>,
31 pub(crate) state: Option<DockerState>,
32}
33
34#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
35pub(crate) struct DockerConfigLabels {
36 #[serde(
37 default,
38 rename = "devcontainer.metadata",
39 deserialize_with = "deserialize_metadata"
40 )]
41 pub(crate) metadata: Option<Vec<HashMap<String, serde_json_lenient::Value>>>,
42}
43
44#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
45#[serde(rename_all = "PascalCase")]
46pub(crate) struct DockerInspectConfig {
47 #[serde(default, deserialize_with = "deserialize_nullable_labels")]
48 pub(crate) labels: DockerConfigLabels,
49 #[serde(rename = "User")]
50 pub(crate) image_user: Option<String>,
51 #[serde(default)]
52 pub(crate) env: Vec<String>,
53}
54
55impl DockerInspectConfig {
56 pub(crate) fn env_as_map(&self) -> Result<HashMap<String, String>, DevContainerError> {
57 let mut map = HashMap::new();
58 for env_var in &self.env {
59 let Some((key, value)) = env_var.split_once('=') else {
60 log::warn!("Skipping environment variable without a value: {env_var}");
61 continue;
62 };
63 map.insert(key.to_string(), value.to_string());
64 }
65 Ok(map)
66 }
67}
68
69#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
70#[serde(rename_all = "PascalCase")]
71pub(crate) struct DockerInspectMount {
72 pub(crate) source: String,
73 pub(crate) destination: String,
74}
75
76#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
77pub(crate) struct DockerComposeServiceBuild {
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub(crate) context: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub(crate) dockerfile: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub(crate) target: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub(crate) args: Option<HashMap<String, String>>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub(crate) additional_contexts: Option<HashMap<String, String>>,
88}
89
90#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
91pub(crate) struct DockerComposeServicePort {
92 #[serde(deserialize_with = "deserialize_string_or_int")]
93 pub(crate) target: String,
94 #[serde(deserialize_with = "deserialize_string_or_int")]
95 pub(crate) published: String,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub(crate) mode: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub(crate) protocol: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub(crate) host_ip: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub(crate) app_protocol: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub(crate) name: Option<String>,
106}
107
108fn deserialize_string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>
109where
110 D: serde::Deserializer<'de>,
111{
112 use serde::Deserialize;
113
114 #[derive(Deserialize)]
115 #[serde(untagged)]
116 enum StringOrInt {
117 String(String),
118 Int(u32),
119 }
120
121 match StringOrInt::deserialize(deserializer)? {
122 StringOrInt::String(s) => Ok(s),
123 StringOrInt::Int(b) => Ok(b.to_string()),
124 }
125}
126
127#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
128pub(crate) struct DockerComposeService {
129 pub(crate) image: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub(crate) entrypoint: Option<Vec<String>>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub(crate) cap_add: Option<Vec<String>>,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub(crate) security_opt: Option<Vec<String>>,
136 #[serde(
137 skip_serializing_if = "Option::is_none",
138 default,
139 deserialize_with = "deserialize_labels"
140 )]
141 pub(crate) labels: Option<HashMap<String, String>>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub(crate) build: Option<DockerComposeServiceBuild>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub(crate) privileged: Option<bool>,
146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
147 pub(crate) volumes: Vec<MountDefinition>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub(crate) env_file: Option<Vec<String>>,
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 pub(crate) ports: Vec<DockerComposeServicePort>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub(crate) network_mode: Option<String>,
154 #[serde(
155 default,
156 skip_serializing_if = "Vec::is_empty",
157 deserialize_with = "deserialize_nullable_vec"
158 )]
159 pub(crate) command: Vec<String>,
160}
161
162#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
163pub(crate) struct DockerComposeVolume {
164 pub(crate) name: String,
165}
166
167#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
168pub(crate) struct DockerComposeConfig {
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub(crate) name: Option<String>,
171 pub(crate) services: HashMap<String, DockerComposeService>,
172 #[serde(default)]
173 pub(crate) volumes: HashMap<String, DockerComposeVolume>,
174}
175
176pub(crate) struct Docker {
177 docker_cli: String,
178 has_buildx: bool,
179}
180
181impl DockerInspect {
182 pub(crate) fn is_running(&self) -> bool {
183 self.state.as_ref().map_or(false, |s| s.running)
184 }
185}
186
187impl Docker {
188 pub(crate) async fn new(docker_cli: &str) -> Self {
189 let has_buildx = if docker_cli == "podman" {
190 false
191 } else {
192 let output = Command::new(docker_cli)
193 .args(["buildx", "version"])
194 .output()
195 .await;
196 output.map(|o| o.status.success()).unwrap_or(false)
197 };
198 if !has_buildx && docker_cli != "podman" {
199 log::info!(
200 "docker buildx not found; dev container builds will use the scratch-image fallback"
201 );
202 }
203 Self {
204 docker_cli: docker_cli.to_string(),
205 has_buildx,
206 }
207 }
208
209 fn is_podman(&self) -> bool {
210 self.docker_cli == "podman"
211 }
212
213 async fn pull_image(&self, image: &String) -> Result<(), DevContainerError> {
214 let mut command = Command::new(&self.docker_cli);
215 command.args(&["pull", "--", image]);
216
217 let output = command.output().await.map_err(|e| {
218 log::error!("Error pulling image: {e}");
219 DevContainerError::ResourceFetchFailed
220 })?;
221
222 if !output.status.success() {
223 let stderr = String::from_utf8_lossy(&output.stderr);
224 log::error!("Non-success result from docker pull: {stderr}");
225 return Err(DevContainerError::ResourceFetchFailed);
226 }
227 Ok(())
228 }
229
230 fn create_docker_query_containers(&self, filters: Vec<String>) -> Command {
231 let mut command = Command::new(&self.docker_cli);
232 command.args(&["ps", "-a"]);
233
234 for filter in filters {
235 command.arg("--filter");
236 command.arg(filter);
237 }
238 command.arg("--format={{ json . }}");
239 command
240 }
241
242 fn create_docker_inspect(&self, id: &str) -> Command {
243 let mut command = Command::new(&self.docker_cli);
244 command.args(&["inspect", "--format={{json . }}", id]);
245 command
246 }
247
248 fn create_docker_compose_config_command(&self, config_files: &Vec<PathBuf>) -> Command {
249 let mut command = Command::new(&self.docker_cli);
250 command.arg("compose");
251 for file_path in config_files {
252 command.args(&["-f", &file_path.display().to_string()]);
253 }
254 command.args(&["config", "--format", "json"]);
255 command
256 }
257}
258
259#[async_trait]
260impl DockerClient for Docker {
261 async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError> {
262 // Try to pull the image, continue on failure; Image may be local only, id a reference to a running container
263 self.pull_image(id).await.ok();
264
265 let command = self.create_docker_inspect(id);
266
267 let Some(docker_inspect): Option<DockerInspect> = evaluate_json_command(command).await?
268 else {
269 log::error!("Docker inspect produced no deserializable output");
270 return Err(DevContainerError::CommandFailed(self.docker_cli.clone()));
271 };
272 Ok(docker_inspect)
273 }
274
275 async fn get_docker_compose_config(
276 &self,
277 config_files: &Vec<PathBuf>,
278 ) -> Result<Option<DockerComposeConfig>, DevContainerError> {
279 let command = self.create_docker_compose_config_command(config_files);
280 evaluate_json_command(command).await
281 }
282
283 async fn docker_compose_build(
284 &self,
285 config_files: &Vec<PathBuf>,
286 project_name: &str,
287 ) -> Result<(), DevContainerError> {
288 let mut command = Command::new(&self.docker_cli);
289 if !self.is_podman() {
290 command.env("DOCKER_BUILDKIT", "1");
291 }
292 command.args(&["compose", "--project-name", project_name]);
293 for docker_compose_file in config_files {
294 command.args(&["-f", &docker_compose_file.display().to_string()]);
295 }
296 command.arg("build");
297
298 let output = command.output().await.map_err(|e| {
299 log::error!("Error running docker compose up: {e}");
300 DevContainerError::CommandFailed(command.get_program().display().to_string())
301 })?;
302
303 if !output.status.success() {
304 let stderr = String::from_utf8_lossy(&output.stderr);
305 log::error!("Non-success status from docker compose up: {}", stderr);
306 return Err(DevContainerError::CommandFailed(
307 command.get_program().display().to_string(),
308 ));
309 }
310
311 Ok(())
312 }
313 async fn run_docker_exec(
314 &self,
315 container_id: &str,
316 remote_folder: &str,
317 user: &str,
318 env: &HashMap<String, String>,
319 inner_command: Command,
320 ) -> Result<(), DevContainerError> {
321 let mut command = Command::new(&self.docker_cli);
322
323 command.args(&["exec", "-w", remote_folder, "-u", user]);
324
325 for (k, v) in env.iter() {
326 command.arg("-e");
327 let env_declaration = format!("{}={}", k, v);
328 command.arg(&env_declaration);
329 }
330
331 command.arg(container_id);
332
333 command.arg("sh");
334
335 let mut inner_program_script: Vec<String> =
336 vec![inner_command.get_program().display().to_string()];
337 let mut args: Vec<String> = inner_command
338 .get_args()
339 .map(|arg| arg.display().to_string())
340 .collect();
341 inner_program_script.append(&mut args);
342 command.args(&["-c", &inner_program_script.join(" ")]);
343
344 let output = command.output().await.map_err(|e| {
345 log::error!("Error running command {e} in container exec");
346 DevContainerError::ContainerNotValid(container_id.to_string())
347 })?;
348 if !output.status.success() {
349 let std_err = String::from_utf8_lossy(&output.stderr);
350 log::error!("Command produced a non-successful output. StdErr: {std_err}");
351 }
352 let std_out = String::from_utf8_lossy(&output.stdout);
353 log::debug!("Command output:\n {std_out}");
354
355 Ok(())
356 }
357 async fn start_container(&self, id: &str) -> Result<(), DevContainerError> {
358 let mut command = Command::new(&self.docker_cli);
359
360 command.args(&["start", id]);
361
362 let output = command.output().await.map_err(|e| {
363 log::error!("Error running docker start: {e}");
364 DevContainerError::CommandFailed(command.get_program().display().to_string())
365 })?;
366
367 if !output.status.success() {
368 let stderr = String::from_utf8_lossy(&output.stderr);
369 log::error!("Non-success status from docker start: {stderr}");
370 return Err(DevContainerError::CommandFailed(
371 command.get_program().display().to_string(),
372 ));
373 }
374
375 Ok(())
376 }
377
378 async fn find_process_by_filters(
379 &self,
380 filters: Vec<String>,
381 ) -> Result<Option<DockerPs>, DevContainerError> {
382 let mut command = self.create_docker_query_containers(filters);
383 let output = command.output().await.map_err(|e| {
384 log::error!("Error running command {:?}: {e}", command);
385 DevContainerError::CommandFailed(command.get_program().display().to_string())
386 })?;
387 if !output.status.success() {
388 let stderr = String::from_utf8_lossy(&output.stderr);
389 log::error!("Non-success status from docker ps: {stderr}");
390 return Err(DevContainerError::CommandFailed(
391 command.get_program().display().to_string(),
392 ));
393 }
394 let raw = String::from_utf8_lossy(&output.stdout);
395 parse_find_process_output(&raw).map_err(|e| {
396 // Preserve the dedicated multi-match error; log and re-wrap other parse failures.
397 if let DevContainerError::MultipleMatchingContainers(_) = &e {
398 e
399 } else {
400 log::error!("Error parsing docker ps output: {e}");
401 DevContainerError::CommandFailed(command.get_program().display().to_string())
402 }
403 })
404 }
405
406 fn docker_cli(&self) -> String {
407 self.docker_cli.clone()
408 }
409
410 fn supports_compose_buildkit(&self) -> bool {
411 self.has_buildx
412 }
413}
414
415/// Parses output of `docker ps -a --format={{ json . }}`. When a single
416/// container matches the label filters, docker emits one JSON object; when
417/// multiple match, it emits newline-delimited JSON (one object per line).
418///
419/// Returns `Ok(None)` for no matches, `Ok(Some(_))` for exactly one match,
420/// and `DevContainerError::MultipleMatchingContainers` for ≥2 matches — the
421/// spec expects identifying labels to be unique per project, so the caller
422/// can't silently pick one.
423fn parse_find_process_output(raw: &str) -> Result<Option<DockerPs>, DevContainerError> {
424 if raw.trim().is_empty() {
425 return Ok(None);
426 }
427 let containers: Vec<DockerPs> = serde_json_lenient::Deserializer::from_str(raw)
428 .into_iter::<DockerPs>()
429 .collect::<Result<_, _>>()
430 .map_err(|e| {
431 DevContainerError::CommandFailed(format!("failed to parse docker ps output: {e}"))
432 })?;
433 match containers.len() {
434 0 => Ok(None),
435 1 => Ok(containers.into_iter().next()),
436 _ => Err(DevContainerError::MultipleMatchingContainers(
437 containers.into_iter().map(|c| c.id).collect(),
438 )),
439 }
440}
441
442#[async_trait]
443pub(crate) trait DockerClient {
444 async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError>;
445 async fn get_docker_compose_config(
446 &self,
447 config_files: &Vec<PathBuf>,
448 ) -> Result<Option<DockerComposeConfig>, DevContainerError>;
449 async fn docker_compose_build(
450 &self,
451 config_files: &Vec<PathBuf>,
452 project_name: &str,
453 ) -> Result<(), DevContainerError>;
454 async fn run_docker_exec(
455 &self,
456 container_id: &str,
457 remote_folder: &str,
458 user: &str,
459 env: &HashMap<String, String>,
460 inner_command: Command,
461 ) -> Result<(), DevContainerError>;
462 async fn start_container(&self, id: &str) -> Result<(), DevContainerError>;
463 async fn find_process_by_filters(
464 &self,
465 filters: Vec<String>,
466 ) -> Result<Option<DockerPs>, DevContainerError>;
467 fn supports_compose_buildkit(&self) -> bool;
468 /// This operates as an escape hatch for more custom uses of the docker API.
469 /// See DevContainerManifest::create_docker_build as an example
470 fn docker_cli(&self) -> String;
471}
472
473fn deserialize_labels<'de, D>(deserializer: D) -> Result<Option<HashMap<String, String>>, D::Error>
474where
475 D: Deserializer<'de>,
476{
477 struct LabelsVisitor;
478
479 impl<'de> de::Visitor<'de> for LabelsVisitor {
480 type Value = Option<HashMap<String, String>>;
481
482 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
483 formatter.write_str("a sequence of strings or a map of string key-value pairs")
484 }
485
486 fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
487 where
488 A: de::SeqAccess<'de>,
489 {
490 let values = Vec::<String>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
491
492 Ok(Some(
493 values
494 .iter()
495 .filter_map(|v| {
496 let (key, value) = v.split_once('=')?;
497 Some((key.to_string(), value.to_string()))
498 })
499 .collect(),
500 ))
501 }
502
503 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
504 where
505 M: de::MapAccess<'de>,
506 {
507 HashMap::<String, String>::deserialize(de::value::MapAccessDeserializer::new(map))
508 .map(|v| Some(v))
509 }
510
511 fn visit_none<E>(self) -> Result<Self::Value, E>
512 where
513 E: de::Error,
514 {
515 Ok(None)
516 }
517
518 fn visit_unit<E>(self) -> Result<Self::Value, E>
519 where
520 E: de::Error,
521 {
522 Ok(None)
523 }
524 }
525
526 deserializer.deserialize_any(LabelsVisitor)
527}
528
529fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
530where
531 D: Deserializer<'de>,
532 T: Deserialize<'de>,
533{
534 Option::<Vec<T>>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
535}
536
537fn deserialize_nullable_labels<'de, D>(deserializer: D) -> Result<DockerConfigLabels, D::Error>
538where
539 D: Deserializer<'de>,
540{
541 Option::<DockerConfigLabels>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
542}
543
544fn deserialize_metadata<'de, D>(
545 deserializer: D,
546) -> Result<Option<Vec<HashMap<String, serde_json_lenient::Value>>>, D::Error>
547where
548 D: Deserializer<'de>,
549{
550 let s: Option<String> = Option::deserialize(deserializer)?;
551 match s {
552 Some(json_string) => {
553 // The devcontainer metadata label can be either a JSON array (e.g. from
554 // image-based devcontainers) or a single JSON object (e.g. from
555 // docker-compose-based devcontainers created by the devcontainer CLI).
556 // Handle both formats.
557 let parsed: Vec<HashMap<String, serde_json_lenient::Value>> =
558 serde_json_lenient::from_str(&json_string).or_else(|_| {
559 let single: HashMap<String, serde_json_lenient::Value> =
560 serde_json_lenient::from_str(&json_string).map_err(|e| {
561 log::error!("Error deserializing metadata: {e}");
562 serde::de::Error::custom(e)
563 })?;
564 Ok(vec![single])
565 })?;
566 Ok(Some(parsed))
567 }
568 None => Ok(None),
569 }
570}
571
572#[cfg(test)]
573mod test {
574 use std::{
575 collections::HashMap,
576 ffi::OsStr,
577 process::{ExitStatus, Output},
578 };
579
580 use crate::{
581 command_json::deserialize_json_output,
582 devcontainer_api::DevContainerError,
583 devcontainer_json::MountDefinition,
584 docker::{
585 Docker, DockerComposeConfig, DockerComposeService, DockerComposeServicePort,
586 DockerComposeVolume, DockerInspect, DockerPs, parse_find_process_output,
587 },
588 };
589
590 #[test]
591 fn should_parse_simple_env_var() {
592 let config = super::DockerInspectConfig {
593 labels: super::DockerConfigLabels { metadata: None },
594 image_user: None,
595 env: vec!["KEY=value".to_string()],
596 };
597
598 let map = config.env_as_map().unwrap();
599 assert_eq!(map.get("KEY").unwrap(), "value");
600 }
601
602 #[test]
603 fn should_parse_env_var_with_equals_in_value() {
604 let config = super::DockerInspectConfig {
605 labels: super::DockerConfigLabels { metadata: None },
606 image_user: None,
607 env: vec!["COMPLEX=key=val other>=1.0".to_string()],
608 };
609
610 let map = config.env_as_map().unwrap();
611 assert_eq!(map.get("COMPLEX").unwrap(), "key=val other>=1.0");
612 }
613
614 #[test]
615 fn should_parse_database_url_with_equals_in_query_string() {
616 let config = super::DockerInspectConfig {
617 labels: super::DockerConfigLabels { metadata: None },
618 image_user: None,
619 env: vec![
620 "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
621 "TEST_DATABASE_URL=postgres://postgres:postgres@db:5432/mydb?sslmode=disable"
622 .to_string(),
623 ],
624 };
625
626 let map = config.env_as_map().unwrap();
627 assert_eq!(
628 map.get("TEST_DATABASE_URL").unwrap(),
629 "postgres://postgres:postgres@db:5432/mydb?sslmode=disable"
630 );
631 }
632
633 #[test]
634 fn should_skip_env_var_without_equals() {
635 let config = super::DockerInspectConfig {
636 labels: super::DockerConfigLabels { metadata: None },
637 image_user: None,
638 env: vec![
639 "VALID_KEY=valid_value".to_string(),
640 "NO_EQUALS_VAR".to_string(),
641 "ANOTHER_VALID=value".to_string(),
642 ],
643 };
644
645 let map = config.env_as_map().unwrap();
646 assert_eq!(map.len(), 2);
647 assert_eq!(map.get("VALID_KEY").unwrap(), "valid_value");
648 assert_eq!(map.get("ANOTHER_VALID").unwrap(), "value");
649 assert!(!map.contains_key("NO_EQUALS_VAR"));
650 }
651
652 #[test]
653 fn should_parse_simple_label() {
654 let json = r#"{"volumes": [], "labels": ["com.example.key=value"]}"#;
655 let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
656 let labels = service.labels.unwrap();
657 assert_eq!(labels.get("com.example.key").unwrap(), "value");
658 }
659
660 #[test]
661 fn should_parse_label_with_equals_in_value() {
662 let json = r#"{"volumes": [], "labels": ["com.example.key=value=with=equals"]}"#;
663 let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
664 let labels = service.labels.unwrap();
665 assert_eq!(labels.get("com.example.key").unwrap(), "value=with=equals");
666 }
667
668 #[test]
669 fn should_create_docker_inspect_command() {
670 let docker = Docker {
671 docker_cli: "docker".to_string(),
672 has_buildx: false,
673 };
674 let given_id = "given_docker_id";
675
676 let command = docker.create_docker_inspect(given_id);
677
678 assert_eq!(
679 command.get_args().collect::<Vec<&OsStr>>(),
680 vec![
681 OsStr::new("inspect"),
682 OsStr::new("--format={{json . }}"),
683 OsStr::new(given_id)
684 ]
685 )
686 }
687
688 #[test]
689 fn should_deserialize_docker_ps_with_filters() {
690 // First, deserializes empty
691 let empty_output = Output {
692 status: ExitStatus::default(),
693 stderr: vec![],
694 stdout: String::from("").into_bytes(),
695 };
696
697 let result: Option<DockerPs> = deserialize_json_output(empty_output).unwrap();
698
699 assert!(result.is_none());
700
701 let full_output = Output {
702 status: ExitStatus::default(),
703 stderr: vec![],
704 stdout: String::from(r#"
705 {
706 "Command": "\"/bin/sh -c 'echo Co…\"",
707 "CreatedAt": "2026-02-04 15:44:21 -0800 PST",
708 "ID": "abdb6ab59573",
709 "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
710 "Labels": "desktop.docker.io/mounts/0/Source=/somepath/cli,desktop.docker.io/mounts/0/SourceKind=hostFile,desktop.docker.io/mounts/0/Target=/workspaces/cli,desktop.docker.io/ports.scheme=v2,dev.containers.features=common,dev.containers.id=base-ubuntu,dev.containers.release=v0.4.24,dev.containers.source=https://github.com/devcontainers/images,dev.containers.timestamp=Fri, 30 Jan 2026 16:52:34 GMT,dev.containers.variant=noble,devcontainer.config_file=/somepath/cli/.devcontainer/dev_container_2/devcontainer.json,devcontainer.local_folder=/somepath/cli,devcontainer.metadata=[{\"id\":\"ghcr.io/devcontainers/features/common-utils:2\"},{\"id\":\"ghcr.io/devcontainers/features/git:1\",\"customizations\":{\"vscode\":{\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes an up-to-date version of Git, built from source as needed, pre-installed and available on the `PATH`.\"}]}}}},{\"remoteUser\":\"vscode\"}],org.opencontainers.image.ref.name=ubuntu,org.opencontainers.image.version=24.04,version=2.1.6",
711 "LocalVolumes": "0",
712 "Mounts": "/host_mnt/User…",
713 "Names": "objective_haslett",
714 "Networks": "bridge",
715 "Platform": {
716 "architecture": "arm64",
717 "os": "linux"
718 },
719 "Ports": "",
720 "RunningFor": "47 hours ago",
721 "Size": "0B",
722 "State": "running",
723 "Status": "Up 47 hours"
724 }
725 "#).into_bytes(),
726 };
727
728 let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
729
730 assert!(result.is_some());
731 let result = result.unwrap();
732 assert_eq!(result.id, "abdb6ab59573".to_string());
733
734 // Podman variant (Id, not ID)
735 let full_output = Output {
736 status: ExitStatus::default(),
737 stderr: vec![],
738 stdout: String::from(r#"
739 {
740 "Command": "\"/bin/sh -c 'echo Co…\"",
741 "CreatedAt": "2026-02-04 15:44:21 -0800 PST",
742 "Id": "abdb6ab59573",
743 "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
744 "Labels": "desktop.docker.io/mounts/0/Source=/somepath/cli,desktop.docker.io/mounts/0/SourceKind=hostFile,desktop.docker.io/mounts/0/Target=/workspaces/cli,desktop.docker.io/ports.scheme=v2,dev.containers.features=common,dev.containers.id=base-ubuntu,dev.containers.release=v0.4.24,dev.containers.source=https://github.com/devcontainers/images,dev.containers.timestamp=Fri, 30 Jan 2026 16:52:34 GMT,dev.containers.variant=noble,devcontainer.config_file=/somepath/cli/.devcontainer/dev_container_2/devcontainer.json,devcontainer.local_folder=/somepath/cli,devcontainer.metadata=[{\"id\":\"ghcr.io/devcontainers/features/common-utils:2\"},{\"id\":\"ghcr.io/devcontainers/features/git:1\",\"customizations\":{\"vscode\":{\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes an up-to-date version of Git, built from source as needed, pre-installed and available on the `PATH`.\"}]}}}},{\"remoteUser\":\"vscode\"}],org.opencontainers.image.ref.name=ubuntu,org.opencontainers.image.version=24.04,version=2.1.6",
745 "LocalVolumes": "0",
746 "Mounts": "/host_mnt/User…",
747 "Names": "objective_haslett",
748 "Networks": "bridge",
749 "Platform": {
750 "architecture": "arm64",
751 "os": "linux"
752 },
753 "Ports": "",
754 "RunningFor": "47 hours ago",
755 "Size": "0B",
756 "State": "running",
757 "Status": "Up 47 hours"
758 }
759 "#).into_bytes(),
760 };
761
762 let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
763
764 assert!(result.is_some());
765 let result = result.unwrap();
766 assert_eq!(result.id, "abdb6ab59573".to_string());
767 }
768
769 #[test]
770 fn parse_find_process_output_none() {
771 assert!(matches!(parse_find_process_output(""), Ok(None)));
772 assert!(matches!(parse_find_process_output(" \n\n"), Ok(None)));
773 }
774
775 #[test]
776 fn parse_find_process_output_single() {
777 let raw = r#"{"ID":"abc123"}"#;
778 let result = parse_find_process_output(raw).expect("single match must parse");
779 assert_eq!(result.unwrap().id, "abc123");
780 }
781
782 #[test]
783 fn parse_find_process_output_multiple_errors() {
784 // `docker ps --format={{ json . }}` emits newline-delimited JSON when
785 // multiple containers match the filters. The spec expects the
786 // identifying labels to be unique per project, so this is an error.
787 let raw = "{\"ID\":\"abc\"}\n{\"ID\":\"def\"}\n";
788 match parse_find_process_output(raw) {
789 Err(DevContainerError::MultipleMatchingContainers(ids)) => {
790 assert_eq!(ids, vec!["abc".to_string(), "def".to_string()]);
791 }
792 other => panic!("expected MultipleMatchingContainers, got {other:?}"),
793 }
794 }
795
796 #[test]
797 fn should_deserialize_object_metadata_from_docker_compose_container() {
798 // The devcontainer CLI writes metadata as a bare JSON object (not an array)
799 // when there is only one metadata entry (e.g. docker-compose with no features).
800 // See https://github.com/devcontainers/cli/issues/1054
801 let given_config = r#"
802 {
803 "Id": "dc4e7b8ff4bf",
804 "Config": {
805 "Labels": {
806 "devcontainer.metadata": "{\"remoteUser\":\"ubuntu\"}"
807 }
808 }
809 }
810 "#;
811 let config = serde_json_lenient::from_str::<DockerInspect>(given_config).unwrap();
812
813 assert!(config.config.labels.metadata.is_some());
814 let metadata = config.config.labels.metadata.unwrap();
815 assert_eq!(metadata.len(), 1);
816 assert!(metadata[0].contains_key("remoteUser"));
817 assert_eq!(metadata[0]["remoteUser"], "ubuntu");
818 }
819
820 #[test]
821 fn should_deserialize_docker_compose_config() {
822 let given_config = r#"
823 {
824 "name": "devcontainer",
825 "networks": {
826 "default": {
827 "name": "devcontainer_default",
828 "ipam": {}
829 }
830 },
831 "services": {
832 "app": {
833 "command": [
834 "sleep",
835 "infinity"
836 ],
837 "depends_on": {
838 "db": {
839 "condition": "service_started",
840 "restart": true,
841 "required": true
842 }
843 },
844 "entrypoint": null,
845 "environment": {
846 "POSTGRES_DB": "postgres",
847 "POSTGRES_HOSTNAME": "localhost",
848 "POSTGRES_PASSWORD": "postgres",
849 "POSTGRES_PORT": "5432",
850 "POSTGRES_USER": "postgres"
851 },
852 "ports": [
853 {
854 "target": "5443",
855 "published": "5442"
856 },
857 {
858 "name": "custom port",
859 "protocol": "udp",
860 "host_ip": "127.0.0.1",
861 "app_protocol": "http",
862 "mode": "host",
863 "target": "8081",
864 "published": "8083"
865
866 }
867 ],
868 "image": "mcr.microsoft.com/devcontainers/rust:2-1-bookworm",
869 "network_mode": "service:db",
870 "volumes": [
871 {
872 "type": "bind",
873 "source": "/path/to",
874 "target": "/workspaces",
875 "bind": {
876 "create_host_path": true
877 }
878 }
879 ]
880 },
881 "db": {
882 "command": null,
883 "entrypoint": null,
884 "environment": {
885 "POSTGRES_DB": "postgres",
886 "POSTGRES_HOSTNAME": "localhost",
887 "POSTGRES_PASSWORD": "postgres",
888 "POSTGRES_PORT": "5432",
889 "POSTGRES_USER": "postgres"
890 },
891 "image": "postgres:14.1",
892 "networks": {
893 "default": null
894 },
895 "restart": "unless-stopped",
896 "volumes": [
897 {
898 "type": "volume",
899 "source": "postgres-data",
900 "target": "/var/lib/postgresql/data",
901 "volume": {}
902 }
903 ]
904 }
905 },
906 "volumes": {
907 "postgres-data": {
908 "name": "devcontainer_postgres-data"
909 }
910 }
911 }
912 "#;
913
914 let docker_compose_config: DockerComposeConfig =
915 serde_json_lenient::from_str(given_config).unwrap();
916
917 let expected_config = DockerComposeConfig {
918 name: Some("devcontainer".to_string()),
919 services: HashMap::from([
920 (
921 "app".to_string(),
922 DockerComposeService {
923 command: vec!["sleep".to_string(), "infinity".to_string()],
924 image: Some(
925 "mcr.microsoft.com/devcontainers/rust:2-1-bookworm".to_string(),
926 ),
927 volumes: vec![MountDefinition {
928 mount_type: Some("bind".to_string()),
929 source: Some("/path/to".to_string()),
930 target: "/workspaces".to_string(),
931 }],
932 network_mode: Some("service:db".to_string()),
933
934 ports: vec![
935 DockerComposeServicePort {
936 target: "5443".to_string(),
937 published: "5442".to_string(),
938 ..Default::default()
939 },
940 DockerComposeServicePort {
941 target: "8081".to_string(),
942 published: "8083".to_string(),
943 mode: Some("host".to_string()),
944 protocol: Some("udp".to_string()),
945 host_ip: Some("127.0.0.1".to_string()),
946 app_protocol: Some("http".to_string()),
947 name: Some("custom port".to_string()),
948 },
949 ],
950 ..Default::default()
951 },
952 ),
953 (
954 "db".to_string(),
955 DockerComposeService {
956 image: Some("postgres:14.1".to_string()),
957 volumes: vec![MountDefinition {
958 mount_type: Some("volume".to_string()),
959 source: Some("postgres-data".to_string()),
960 target: "/var/lib/postgresql/data".to_string(),
961 }],
962 ..Default::default()
963 },
964 ),
965 ]),
966 volumes: HashMap::from([(
967 "postgres-data".to_string(),
968 DockerComposeVolume {
969 name: "devcontainer_postgres-data".to_string(),
970 },
971 )]),
972 };
973
974 assert_eq!(docker_compose_config, expected_config);
975 }
976
977 #[test]
978 fn should_deserialize_compose_labels_as_map() {
979 let given_config = r#"
980 {
981 "name": "devcontainer",
982 "services": {
983 "app": {
984 "image": "node:22-alpine",
985 "volumes": [],
986 "labels": {
987 "com.example.test": "value",
988 "another.label": "another-value"
989 }
990 }
991 }
992 }
993 "#;
994
995 let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
996 let service = config.services.get("app").unwrap();
997 let labels = service.labels.clone().unwrap();
998 assert_eq!(
999 labels,
1000 HashMap::from([
1001 ("another.label".to_string(), "another-value".to_string()),
1002 ("com.example.test".to_string(), "value".to_string())
1003 ])
1004 );
1005 }
1006
1007 #[test]
1008 fn should_deserialize_compose_labels_as_array() {
1009 let given_config = r#"
1010 {
1011 "name": "devcontainer",
1012 "services": {
1013 "app": {
1014 "image": "node:22-alpine",
1015 "volumes": [],
1016 "labels": ["com.example.test=value"]
1017 }
1018 }
1019 }
1020 "#;
1021
1022 let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1023 let service = config.services.get("app").unwrap();
1024 assert_eq!(
1025 service.labels,
1026 Some(HashMap::from([(
1027 "com.example.test".to_string(),
1028 "value".to_string()
1029 )]))
1030 );
1031 }
1032
1033 #[test]
1034 fn should_deserialize_compose_without_volumes() {
1035 let given_config = r#"
1036 {
1037 "name": "devcontainer",
1038 "services": {
1039 "app": {
1040 "image": "node:22-alpine",
1041 "volumes": []
1042 }
1043 }
1044 }
1045 "#;
1046
1047 let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1048 assert!(config.volumes.is_empty());
1049 }
1050
1051 #[test]
1052 fn should_deserialize_compose_with_missing_volumes_field() {
1053 let given_config = r#"
1054 {
1055 "name": "devcontainer",
1056 "services": {
1057 "sidecar": {
1058 "image": "ubuntu:24.04"
1059 }
1060 }
1061 }
1062 "#;
1063
1064 let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1065 let service = config.services.get("sidecar").unwrap();
1066 assert!(service.volumes.is_empty());
1067 }
1068
1069 #[test]
1070 fn should_deserialize_compose_volume_without_source() {
1071 let given_config = r#"
1072 {
1073 "name": "devcontainer",
1074 "services": {
1075 "app": {
1076 "image": "ubuntu:24.04",
1077 "volumes": [
1078 {
1079 "type": "tmpfs",
1080 "target": "/tmp"
1081 }
1082 ]
1083 }
1084 }
1085 }
1086 "#;
1087
1088 let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1089 let service = config.services.get("app").unwrap();
1090 assert_eq!(service.volumes.len(), 1);
1091 assert_eq!(service.volumes[0].source, None);
1092 assert_eq!(service.volumes[0].target, "/tmp");
1093 assert_eq!(service.volumes[0].mount_type, Some("tmpfs".to_string()));
1094 }
1095
1096 #[test]
1097 fn should_deserialize_inspect_without_labels() {
1098 let given_config = r#"
1099 {
1100 "Id": "sha256:abc123",
1101 "Config": {
1102 "Env": ["PATH=/usr/bin"],
1103 "Cmd": ["node"],
1104 "WorkingDir": "/"
1105 }
1106 }
1107 "#;
1108
1109 let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1110 assert!(inspect.config.labels.metadata.is_none());
1111 assert!(inspect.config.image_user.is_none());
1112 }
1113
1114 #[test]
1115 fn should_deserialize_inspect_with_null_labels() {
1116 let given_config = r#"
1117 {
1118 "Id": "sha256:abc123",
1119 "Config": {
1120 "Labels": null,
1121 "Env": ["PATH=/usr/bin"]
1122 }
1123 }
1124 "#;
1125
1126 let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1127 assert!(inspect.config.labels.metadata.is_none());
1128 }
1129
1130 #[test]
1131 fn should_deserialize_inspect_with_labels_but_no_metadata() {
1132 let given_config = r#"
1133 {
1134 "Id": "sha256:abc123",
1135 "Config": {
1136 "Labels": {
1137 "com.example.test": "value"
1138 },
1139 "Env": ["PATH=/usr/bin"]
1140 }
1141 }
1142 "#;
1143
1144 let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1145 assert!(inspect.config.labels.metadata.is_none());
1146 }
1147}