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::error!("Unable to parse {env_var} into an environment key-value");
61 return Err(DevContainerError::DevContainerParseFailed);
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) args: Option<HashMap<String, String>>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub(crate) additional_contexts: Option<HashMap<String, String>>,
86}
87
88#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
89pub(crate) struct DockerComposeServicePort {
90 #[serde(deserialize_with = "deserialize_string_or_int")]
91 pub(crate) target: String,
92 #[serde(deserialize_with = "deserialize_string_or_int")]
93 pub(crate) published: String,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub(crate) mode: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub(crate) protocol: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub(crate) host_ip: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub(crate) app_protocol: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub(crate) name: Option<String>,
104}
105
106fn deserialize_string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>
107where
108 D: serde::Deserializer<'de>,
109{
110 use serde::Deserialize;
111
112 #[derive(Deserialize)]
113 #[serde(untagged)]
114 enum StringOrInt {
115 String(String),
116 Int(u32),
117 }
118
119 match StringOrInt::deserialize(deserializer)? {
120 StringOrInt::String(s) => Ok(s),
121 StringOrInt::Int(b) => Ok(b.to_string()),
122 }
123}
124
125#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
126pub(crate) struct DockerComposeService {
127 pub(crate) image: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub(crate) entrypoint: Option<Vec<String>>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub(crate) cap_add: Option<Vec<String>>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub(crate) security_opt: Option<Vec<String>>,
134 #[serde(
135 skip_serializing_if = "Option::is_none",
136 default,
137 deserialize_with = "deserialize_labels"
138 )]
139 pub(crate) labels: Option<HashMap<String, String>>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub(crate) build: Option<DockerComposeServiceBuild>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub(crate) privileged: Option<bool>,
144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
145 pub(crate) volumes: Vec<MountDefinition>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub(crate) env_file: Option<Vec<String>>,
148 #[serde(default, skip_serializing_if = "Vec::is_empty")]
149 pub(crate) ports: Vec<DockerComposeServicePort>,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub(crate) network_mode: Option<String>,
152 #[serde(
153 default,
154 skip_serializing_if = "Vec::is_empty",
155 deserialize_with = "deserialize_nullable_vec"
156 )]
157 pub(crate) command: Vec<String>,
158}
159
160#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
161pub(crate) struct DockerComposeVolume {
162 pub(crate) name: String,
163}
164
165#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
166pub(crate) struct DockerComposeConfig {
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub(crate) name: Option<String>,
169 pub(crate) services: HashMap<String, DockerComposeService>,
170 #[serde(default)]
171 pub(crate) volumes: HashMap<String, DockerComposeVolume>,
172}
173
174pub(crate) struct Docker {
175 docker_cli: String,
176}
177
178impl DockerInspect {
179 pub(crate) fn is_running(&self) -> bool {
180 self.state.as_ref().map_or(false, |s| s.running)
181 }
182}
183
184impl Docker {
185 pub(crate) fn new(docker_cli: &str) -> Self {
186 Self {
187 docker_cli: docker_cli.to_string(),
188 }
189 }
190
191 fn is_podman(&self) -> bool {
192 self.docker_cli == "podman"
193 }
194
195 async fn pull_image(&self, image: &String) -> Result<(), DevContainerError> {
196 let mut command = Command::new(&self.docker_cli);
197 command.args(&["pull", image]);
198
199 let output = command.output().await.map_err(|e| {
200 log::error!("Error pulling image: {e}");
201 DevContainerError::ResourceFetchFailed
202 })?;
203
204 if !output.status.success() {
205 let stderr = String::from_utf8_lossy(&output.stderr);
206 log::error!("Non-success result from docker pull: {stderr}");
207 return Err(DevContainerError::ResourceFetchFailed);
208 }
209 Ok(())
210 }
211
212 fn create_docker_query_containers(&self, filters: Vec<String>) -> Command {
213 let mut command = Command::new(&self.docker_cli);
214 command.args(&["ps", "-a"]);
215
216 for filter in filters {
217 command.arg("--filter");
218 command.arg(filter);
219 }
220 command.arg("--format={{ json . }}");
221 command
222 }
223
224 fn create_docker_inspect(&self, id: &str) -> Command {
225 let mut command = Command::new(&self.docker_cli);
226 command.args(&["inspect", "--format={{json . }}", id]);
227 command
228 }
229
230 fn create_docker_compose_config_command(&self, config_files: &Vec<PathBuf>) -> Command {
231 let mut command = Command::new(&self.docker_cli);
232 command.arg("compose");
233 for file_path in config_files {
234 command.args(&["-f", &file_path.display().to_string()]);
235 }
236 command.args(&["config", "--format", "json"]);
237 command
238 }
239}
240
241#[async_trait]
242impl DockerClient for Docker {
243 async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError> {
244 // Try to pull the image, continue on failure; Image may be local only, id a reference to a running container
245 self.pull_image(id).await.ok();
246
247 let command = self.create_docker_inspect(id);
248
249 let Some(docker_inspect): Option<DockerInspect> = evaluate_json_command(command).await?
250 else {
251 log::error!("Docker inspect produced no deserializable output");
252 return Err(DevContainerError::CommandFailed(self.docker_cli.clone()));
253 };
254 Ok(docker_inspect)
255 }
256
257 async fn get_docker_compose_config(
258 &self,
259 config_files: &Vec<PathBuf>,
260 ) -> Result<Option<DockerComposeConfig>, DevContainerError> {
261 let command = self.create_docker_compose_config_command(config_files);
262 evaluate_json_command(command).await
263 }
264
265 async fn docker_compose_build(
266 &self,
267 config_files: &Vec<PathBuf>,
268 project_name: &str,
269 ) -> Result<(), DevContainerError> {
270 let mut command = Command::new(&self.docker_cli);
271 if !self.is_podman() {
272 command.env("DOCKER_BUILDKIT", "1");
273 }
274 command.args(&["compose", "--project-name", project_name]);
275 for docker_compose_file in config_files {
276 command.args(&["-f", &docker_compose_file.display().to_string()]);
277 }
278 command.arg("build");
279
280 let output = command.output().await.map_err(|e| {
281 log::error!("Error running docker compose up: {e}");
282 DevContainerError::CommandFailed(command.get_program().display().to_string())
283 })?;
284
285 if !output.status.success() {
286 let stderr = String::from_utf8_lossy(&output.stderr);
287 log::error!("Non-success status from docker compose up: {}", stderr);
288 return Err(DevContainerError::CommandFailed(
289 command.get_program().display().to_string(),
290 ));
291 }
292
293 Ok(())
294 }
295 async fn run_docker_exec(
296 &self,
297 container_id: &str,
298 remote_folder: &str,
299 user: &str,
300 env: &HashMap<String, String>,
301 inner_command: Command,
302 ) -> Result<(), DevContainerError> {
303 let mut command = Command::new(&self.docker_cli);
304
305 command.args(&["exec", "-w", remote_folder, "-u", user]);
306
307 for (k, v) in env.iter() {
308 command.arg("-e");
309 let env_declaration = format!("{}={}", k, v);
310 command.arg(&env_declaration);
311 }
312
313 command.arg(container_id);
314
315 command.arg("sh");
316
317 let mut inner_program_script: Vec<String> =
318 vec![inner_command.get_program().display().to_string()];
319 let mut args: Vec<String> = inner_command
320 .get_args()
321 .map(|arg| arg.display().to_string())
322 .collect();
323 inner_program_script.append(&mut args);
324 command.args(&["-c", &inner_program_script.join(" ")]);
325
326 let output = command.output().await.map_err(|e| {
327 log::error!("Error running command {e} in container exec");
328 DevContainerError::ContainerNotValid(container_id.to_string())
329 })?;
330 if !output.status.success() {
331 let std_err = String::from_utf8_lossy(&output.stderr);
332 log::error!("Command produced a non-successful output. StdErr: {std_err}");
333 }
334 let std_out = String::from_utf8_lossy(&output.stdout);
335 log::debug!("Command output:\n {std_out}");
336
337 Ok(())
338 }
339 async fn start_container(&self, id: &str) -> Result<(), DevContainerError> {
340 let mut command = Command::new(&self.docker_cli);
341
342 command.args(&["start", id]);
343
344 let output = command.output().await.map_err(|e| {
345 log::error!("Error running docker start: {e}");
346 DevContainerError::CommandFailed(command.get_program().display().to_string())
347 })?;
348
349 if !output.status.success() {
350 let stderr = String::from_utf8_lossy(&output.stderr);
351 log::error!("Non-success status from docker start: {stderr}");
352 return Err(DevContainerError::CommandFailed(
353 command.get_program().display().to_string(),
354 ));
355 }
356
357 Ok(())
358 }
359
360 async fn find_process_by_filters(
361 &self,
362 filters: Vec<String>,
363 ) -> Result<Option<DockerPs>, DevContainerError> {
364 let command = self.create_docker_query_containers(filters);
365 evaluate_json_command(command).await
366 }
367
368 fn docker_cli(&self) -> String {
369 self.docker_cli.clone()
370 }
371
372 fn supports_compose_buildkit(&self) -> bool {
373 !self.is_podman()
374 }
375}
376
377#[async_trait]
378pub(crate) trait DockerClient {
379 async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError>;
380 async fn get_docker_compose_config(
381 &self,
382 config_files: &Vec<PathBuf>,
383 ) -> Result<Option<DockerComposeConfig>, DevContainerError>;
384 async fn docker_compose_build(
385 &self,
386 config_files: &Vec<PathBuf>,
387 project_name: &str,
388 ) -> Result<(), DevContainerError>;
389 async fn run_docker_exec(
390 &self,
391 container_id: &str,
392 remote_folder: &str,
393 user: &str,
394 env: &HashMap<String, String>,
395 inner_command: Command,
396 ) -> Result<(), DevContainerError>;
397 async fn start_container(&self, id: &str) -> Result<(), DevContainerError>;
398 async fn find_process_by_filters(
399 &self,
400 filters: Vec<String>,
401 ) -> Result<Option<DockerPs>, DevContainerError>;
402 fn supports_compose_buildkit(&self) -> bool;
403 /// This operates as an escape hatch for more custom uses of the docker API.
404 /// See DevContainerManifest::create_docker_build as an example
405 fn docker_cli(&self) -> String;
406}
407
408fn deserialize_labels<'de, D>(deserializer: D) -> Result<Option<HashMap<String, String>>, D::Error>
409where
410 D: Deserializer<'de>,
411{
412 struct LabelsVisitor;
413
414 impl<'de> de::Visitor<'de> for LabelsVisitor {
415 type Value = Option<HashMap<String, String>>;
416
417 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
418 formatter.write_str("a sequence of strings or a map of string key-value pairs")
419 }
420
421 fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
422 where
423 A: de::SeqAccess<'de>,
424 {
425 let values = Vec::<String>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
426
427 Ok(Some(
428 values
429 .iter()
430 .filter_map(|v| {
431 let (key, value) = v.split_once('=')?;
432 Some((key.to_string(), value.to_string()))
433 })
434 .collect(),
435 ))
436 }
437
438 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
439 where
440 M: de::MapAccess<'de>,
441 {
442 HashMap::<String, String>::deserialize(de::value::MapAccessDeserializer::new(map))
443 .map(|v| Some(v))
444 }
445
446 fn visit_none<E>(self) -> Result<Self::Value, E>
447 where
448 E: de::Error,
449 {
450 Ok(None)
451 }
452
453 fn visit_unit<E>(self) -> Result<Self::Value, E>
454 where
455 E: de::Error,
456 {
457 Ok(None)
458 }
459 }
460
461 deserializer.deserialize_any(LabelsVisitor)
462}
463
464fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
465where
466 D: Deserializer<'de>,
467 T: Deserialize<'de>,
468{
469 Option::<Vec<T>>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
470}
471
472fn deserialize_nullable_labels<'de, D>(deserializer: D) -> Result<DockerConfigLabels, D::Error>
473where
474 D: Deserializer<'de>,
475{
476 Option::<DockerConfigLabels>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
477}
478
479fn deserialize_metadata<'de, D>(
480 deserializer: D,
481) -> Result<Option<Vec<HashMap<String, serde_json_lenient::Value>>>, D::Error>
482where
483 D: Deserializer<'de>,
484{
485 let s: Option<String> = Option::deserialize(deserializer)?;
486 match s {
487 Some(json_string) => {
488 let parsed: Vec<HashMap<String, serde_json_lenient::Value>> =
489 serde_json_lenient::from_str(&json_string).map_err(|e| {
490 log::error!("Error deserializing metadata: {e}");
491 serde::de::Error::custom(e)
492 })?;
493 Ok(Some(parsed))
494 }
495 None => Ok(None),
496 }
497}
498
499pub(crate) fn get_remote_dir_from_config(
500 config: &DockerInspect,
501 local_dir: String,
502) -> Result<String, DevContainerError> {
503 let local_path = PathBuf::from(&local_dir);
504
505 let Some(mounts) = &config.mounts else {
506 log::error!("No mounts defined for container");
507 return Err(DevContainerError::ContainerNotValid(config.id.clone()));
508 };
509
510 for mount in mounts {
511 // Sometimes docker will mount the local filesystem on host_mnt for system isolation
512 let mount_source = PathBuf::from(&mount.source.trim_start_matches("/host_mnt"));
513 if let Ok(relative_path_to_project) = local_path.strip_prefix(&mount_source) {
514 let remote_dir = format!(
515 "{}/{}",
516 &mount.destination,
517 relative_path_to_project.display()
518 );
519 return Ok(remote_dir);
520 }
521 if mount.source == local_dir {
522 return Ok(mount.destination.clone());
523 }
524 }
525 log::error!("No mounts to local folder");
526 Err(DevContainerError::ContainerNotValid(config.id.clone()))
527}
528
529#[cfg(test)]
530mod test {
531 use std::{
532 collections::HashMap,
533 ffi::OsStr,
534 process::{ExitStatus, Output},
535 };
536
537 use crate::{
538 command_json::deserialize_json_output,
539 devcontainer_json::MountDefinition,
540 docker::{
541 Docker, DockerComposeConfig, DockerComposeService, DockerComposeServicePort,
542 DockerComposeVolume, DockerInspect, DockerPs, get_remote_dir_from_config,
543 },
544 };
545
546 #[test]
547 fn should_parse_simple_env_var() {
548 let config = super::DockerInspectConfig {
549 labels: super::DockerConfigLabels { metadata: None },
550 image_user: None,
551 env: vec!["KEY=value".to_string()],
552 };
553
554 let map = config.env_as_map().unwrap();
555 assert_eq!(map.get("KEY").unwrap(), "value");
556 }
557
558 #[test]
559 fn should_parse_env_var_with_equals_in_value() {
560 let config = super::DockerInspectConfig {
561 labels: super::DockerConfigLabels { metadata: None },
562 image_user: None,
563 env: vec!["COMPLEX=key=val other>=1.0".to_string()],
564 };
565
566 let map = config.env_as_map().unwrap();
567 assert_eq!(map.get("COMPLEX").unwrap(), "key=val other>=1.0");
568 }
569
570 #[test]
571 fn should_parse_simple_label() {
572 let json = r#"{"volumes": [], "labels": ["com.example.key=value"]}"#;
573 let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
574 let labels = service.labels.unwrap();
575 assert_eq!(labels.get("com.example.key").unwrap(), "value");
576 }
577
578 #[test]
579 fn should_parse_label_with_equals_in_value() {
580 let json = r#"{"volumes": [], "labels": ["com.example.key=value=with=equals"]}"#;
581 let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
582 let labels = service.labels.unwrap();
583 assert_eq!(labels.get("com.example.key").unwrap(), "value=with=equals");
584 }
585
586 #[test]
587 fn should_create_docker_inspect_command() {
588 let docker = Docker::new("docker");
589 let given_id = "given_docker_id";
590
591 let command = docker.create_docker_inspect(given_id);
592
593 assert_eq!(
594 command.get_args().collect::<Vec<&OsStr>>(),
595 vec![
596 OsStr::new("inspect"),
597 OsStr::new("--format={{json . }}"),
598 OsStr::new(given_id)
599 ]
600 )
601 }
602
603 #[test]
604 fn should_deserialize_docker_ps_with_filters() {
605 // First, deserializes empty
606 let empty_output = Output {
607 status: ExitStatus::default(),
608 stderr: vec![],
609 stdout: String::from("").into_bytes(),
610 };
611
612 let result: Option<DockerPs> = deserialize_json_output(empty_output).unwrap();
613
614 assert!(result.is_none());
615
616 let full_output = Output {
617 status: ExitStatus::default(),
618 stderr: vec![],
619 stdout: String::from(r#"
620 {
621 "Command": "\"/bin/sh -c 'echo Co…\"",
622 "CreatedAt": "2026-02-04 15:44:21 -0800 PST",
623 "ID": "abdb6ab59573",
624 "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
625 "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",
626 "LocalVolumes": "0",
627 "Mounts": "/host_mnt/User…",
628 "Names": "objective_haslett",
629 "Networks": "bridge",
630 "Platform": {
631 "architecture": "arm64",
632 "os": "linux"
633 },
634 "Ports": "",
635 "RunningFor": "47 hours ago",
636 "Size": "0B",
637 "State": "running",
638 "Status": "Up 47 hours"
639 }
640 "#).into_bytes(),
641 };
642
643 let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
644
645 assert!(result.is_some());
646 let result = result.unwrap();
647 assert_eq!(result.id, "abdb6ab59573".to_string());
648
649 // Podman variant (Id, not ID)
650 let full_output = Output {
651 status: ExitStatus::default(),
652 stderr: vec![],
653 stdout: String::from(r#"
654 {
655 "Command": "\"/bin/sh -c 'echo Co…\"",
656 "CreatedAt": "2026-02-04 15:44:21 -0800 PST",
657 "Id": "abdb6ab59573",
658 "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
659 "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",
660 "LocalVolumes": "0",
661 "Mounts": "/host_mnt/User…",
662 "Names": "objective_haslett",
663 "Networks": "bridge",
664 "Platform": {
665 "architecture": "arm64",
666 "os": "linux"
667 },
668 "Ports": "",
669 "RunningFor": "47 hours ago",
670 "Size": "0B",
671 "State": "running",
672 "Status": "Up 47 hours"
673 }
674 "#).into_bytes(),
675 };
676
677 let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
678
679 assert!(result.is_some());
680 let result = result.unwrap();
681 assert_eq!(result.id, "abdb6ab59573".to_string());
682 }
683
684 #[test]
685 fn should_get_target_dir_from_docker_inspect() {
686 let given_config = r#"
687 {
688 "Id": "abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85",
689 "Created": "2026-02-04T23:44:21.802688084Z",
690 "Path": "/bin/sh",
691 "Args": [
692 "-c",
693 "echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
694 "-"
695 ],
696 "State": {
697 "Status": "running",
698 "Running": true,
699 "Paused": false,
700 "Restarting": false,
701 "OOMKilled": false,
702 "Dead": false,
703 "Pid": 23087,
704 "ExitCode": 0,
705 "Error": "",
706 "StartedAt": "2026-02-04T23:44:21.954875084Z",
707 "FinishedAt": "0001-01-01T00:00:00Z"
708 },
709 "Image": "sha256:3dcb059253b2ebb44de3936620e1cff3dadcd2c1c982d579081ca8128c1eb319",
710 "ResolvConfPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/resolv.conf",
711 "HostnamePath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hostname",
712 "HostsPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hosts",
713 "LogPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85-json.log",
714 "Name": "/objective_haslett",
715 "RestartCount": 0,
716 "Driver": "overlayfs",
717 "Platform": "linux",
718 "MountLabel": "",
719 "ProcessLabel": "",
720 "AppArmorProfile": "",
721 "ExecIDs": [
722 "008019d93df4107fcbba78bcc6e1ed7e121844f36c26aca1a56284655a6adb53"
723 ],
724 "HostConfig": {
725 "Binds": null,
726 "ContainerIDFile": "",
727 "LogConfig": {
728 "Type": "json-file",
729 "Config": {}
730 },
731 "NetworkMode": "bridge",
732 "PortBindings": {},
733 "RestartPolicy": {
734 "Name": "no",
735 "MaximumRetryCount": 0
736 },
737 "AutoRemove": false,
738 "VolumeDriver": "",
739 "VolumesFrom": null,
740 "ConsoleSize": [
741 0,
742 0
743 ],
744 "CapAdd": null,
745 "CapDrop": null,
746 "CgroupnsMode": "private",
747 "Dns": [],
748 "DnsOptions": [],
749 "DnsSearch": [],
750 "ExtraHosts": null,
751 "GroupAdd": null,
752 "IpcMode": "private",
753 "Cgroup": "",
754 "Links": null,
755 "OomScoreAdj": 0,
756 "PidMode": "",
757 "Privileged": false,
758 "PublishAllPorts": false,
759 "ReadonlyRootfs": false,
760 "SecurityOpt": null,
761 "UTSMode": "",
762 "UsernsMode": "",
763 "ShmSize": 67108864,
764 "Runtime": "runc",
765 "Isolation": "",
766 "CpuShares": 0,
767 "Memory": 0,
768 "NanoCpus": 0,
769 "CgroupParent": "",
770 "BlkioWeight": 0,
771 "BlkioWeightDevice": [],
772 "BlkioDeviceReadBps": [],
773 "BlkioDeviceWriteBps": [],
774 "BlkioDeviceReadIOps": [],
775 "BlkioDeviceWriteIOps": [],
776 "CpuPeriod": 0,
777 "CpuQuota": 0,
778 "CpuRealtimePeriod": 0,
779 "CpuRealtimeRuntime": 0,
780 "CpusetCpus": "",
781 "CpusetMems": "",
782 "Devices": [],
783 "DeviceCgroupRules": null,
784 "DeviceRequests": null,
785 "MemoryReservation": 0,
786 "MemorySwap": 0,
787 "MemorySwappiness": null,
788 "OomKillDisable": null,
789 "PidsLimit": null,
790 "Ulimits": [],
791 "CpuCount": 0,
792 "CpuPercent": 0,
793 "IOMaximumIOps": 0,
794 "IOMaximumBandwidth": 0,
795 "Mounts": [
796 {
797 "Type": "bind",
798 "Source": "/somepath/cli",
799 "Target": "/workspaces/cli",
800 "Consistency": "cached"
801 }
802 ],
803 "MaskedPaths": [
804 "/proc/asound",
805 "/proc/acpi",
806 "/proc/interrupts",
807 "/proc/kcore",
808 "/proc/keys",
809 "/proc/latency_stats",
810 "/proc/timer_list",
811 "/proc/timer_stats",
812 "/proc/sched_debug",
813 "/proc/scsi",
814 "/sys/firmware",
815 "/sys/devices/virtual/powercap"
816 ],
817 "ReadonlyPaths": [
818 "/proc/bus",
819 "/proc/fs",
820 "/proc/irq",
821 "/proc/sys",
822 "/proc/sysrq-trigger"
823 ]
824 },
825 "GraphDriver": {
826 "Data": null,
827 "Name": "overlayfs"
828 },
829 "Mounts": [
830 {
831 "Type": "bind",
832 "Source": "/somepath/cli",
833 "Destination": "/workspaces/cli",
834 "Mode": "",
835 "RW": true,
836 "Propagation": "rprivate"
837 }
838 ],
839 "Config": {
840 "Hostname": "abdb6ab59573",
841 "Domainname": "",
842 "User": "root",
843 "AttachStdin": false,
844 "AttachStdout": true,
845 "AttachStderr": true,
846 "Tty": false,
847 "OpenStdin": false,
848 "StdinOnce": false,
849 "Env": [
850 "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
851 ],
852 "Cmd": [
853 "-c",
854 "echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
855 "-"
856 ],
857 "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
858 "Volumes": null,
859 "WorkingDir": "",
860 "Entrypoint": [
861 "/bin/sh"
862 ],
863 "OnBuild": null,
864 "Labels": {
865 "dev.containers.features": "common",
866 "dev.containers.id": "base-ubuntu",
867 "dev.containers.release": "v0.4.24",
868 "dev.containers.source": "https://github.com/devcontainers/images",
869 "dev.containers.timestamp": "Fri, 30 Jan 2026 16:52:34 GMT",
870 "dev.containers.variant": "noble",
871 "devcontainer.config_file": "/somepath/cli/.devcontainer/dev_container_2/devcontainer.json",
872 "devcontainer.local_folder": "/somepath/cli",
873 "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\"}]",
874 "org.opencontainers.image.ref.name": "ubuntu",
875 "org.opencontainers.image.version": "24.04",
876 "version": "2.1.6"
877 },
878 "StopTimeout": 1
879 },
880 "NetworkSettings": {
881 "Bridge": "",
882 "SandboxID": "2a94990d542fe532deb75f1cc67f761df2d669e3b41161f914079e88516cc54b",
883 "SandboxKey": "/var/run/docker/netns/2a94990d542f",
884 "Ports": {},
885 "HairpinMode": false,
886 "LinkLocalIPv6Address": "",
887 "LinkLocalIPv6PrefixLen": 0,
888 "SecondaryIPAddresses": null,
889 "SecondaryIPv6Addresses": null,
890 "EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570",
891 "Gateway": "172.17.0.1",
892 "GlobalIPv6Address": "",
893 "GlobalIPv6PrefixLen": 0,
894 "IPAddress": "172.17.0.3",
895 "IPPrefixLen": 16,
896 "IPv6Gateway": "",
897 "MacAddress": "",
898 "Networks": {
899 "bridge": {
900 "IPAMConfig": null,
901 "Links": null,
902 "Aliases": null,
903 "MacAddress": "9a:ec:af:8a:ac:81",
904 "DriverOpts": null,
905 "GwPriority": 0,
906 "NetworkID": "51bb8ccc4d1281db44f16d915963fc728619d4a68e2f90e5ea8f1cb94885063e",
907 "EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570",
908 "Gateway": "172.17.0.1",
909 "IPAddress": "172.17.0.3",
910 "IPPrefixLen": 16,
911 "IPv6Gateway": "",
912 "GlobalIPv6Address": "",
913 "GlobalIPv6PrefixLen": 0,
914 "DNSNames": null
915 }
916 }
917 },
918 "ImageManifestDescriptor": {
919 "mediaType": "application/vnd.oci.image.manifest.v1+json",
920 "digest": "sha256:39c3436527190561948236894c55b59fa58aa08d68d8867e703c8d5ab72a3593",
921 "size": 2195,
922 "platform": {
923 "architecture": "arm64",
924 "os": "linux"
925 }
926 }
927 }
928 "#;
929 let config = serde_json_lenient::from_str::<DockerInspect>(given_config).unwrap();
930
931 let target_dir = get_remote_dir_from_config(&config, "/somepath/cli".to_string());
932
933 assert!(target_dir.is_ok());
934 assert_eq!(target_dir.unwrap(), "/workspaces/cli/".to_string());
935 }
936
937 #[test]
938 fn should_deserialize_docker_compose_config() {
939 let given_config = r#"
940 {
941 "name": "devcontainer",
942 "networks": {
943 "default": {
944 "name": "devcontainer_default",
945 "ipam": {}
946 }
947 },
948 "services": {
949 "app": {
950 "command": [
951 "sleep",
952 "infinity"
953 ],
954 "depends_on": {
955 "db": {
956 "condition": "service_started",
957 "restart": true,
958 "required": true
959 }
960 },
961 "entrypoint": null,
962 "environment": {
963 "POSTGRES_DB": "postgres",
964 "POSTGRES_HOSTNAME": "localhost",
965 "POSTGRES_PASSWORD": "postgres",
966 "POSTGRES_PORT": "5432",
967 "POSTGRES_USER": "postgres"
968 },
969 "ports": [
970 {
971 "target": "5443",
972 "published": "5442"
973 },
974 {
975 "name": "custom port",
976 "protocol": "udp",
977 "host_ip": "127.0.0.1",
978 "app_protocol": "http",
979 "mode": "host",
980 "target": "8081",
981 "published": "8083"
982
983 }
984 ],
985 "image": "mcr.microsoft.com/devcontainers/rust:2-1-bookworm",
986 "network_mode": "service:db",
987 "volumes": [
988 {
989 "type": "bind",
990 "source": "/path/to",
991 "target": "/workspaces",
992 "bind": {
993 "create_host_path": true
994 }
995 }
996 ]
997 },
998 "db": {
999 "command": null,
1000 "entrypoint": null,
1001 "environment": {
1002 "POSTGRES_DB": "postgres",
1003 "POSTGRES_HOSTNAME": "localhost",
1004 "POSTGRES_PASSWORD": "postgres",
1005 "POSTGRES_PORT": "5432",
1006 "POSTGRES_USER": "postgres"
1007 },
1008 "image": "postgres:14.1",
1009 "networks": {
1010 "default": null
1011 },
1012 "restart": "unless-stopped",
1013 "volumes": [
1014 {
1015 "type": "volume",
1016 "source": "postgres-data",
1017 "target": "/var/lib/postgresql/data",
1018 "volume": {}
1019 }
1020 ]
1021 }
1022 },
1023 "volumes": {
1024 "postgres-data": {
1025 "name": "devcontainer_postgres-data"
1026 }
1027 }
1028 }
1029 "#;
1030
1031 let docker_compose_config: DockerComposeConfig =
1032 serde_json_lenient::from_str(given_config).unwrap();
1033
1034 let expected_config = DockerComposeConfig {
1035 name: Some("devcontainer".to_string()),
1036 services: HashMap::from([
1037 (
1038 "app".to_string(),
1039 DockerComposeService {
1040 command: vec!["sleep".to_string(), "infinity".to_string()],
1041 image: Some(
1042 "mcr.microsoft.com/devcontainers/rust:2-1-bookworm".to_string(),
1043 ),
1044 volumes: vec![MountDefinition {
1045 mount_type: Some("bind".to_string()),
1046 source: Some("/path/to".to_string()),
1047 target: "/workspaces".to_string(),
1048 }],
1049 network_mode: Some("service:db".to_string()),
1050
1051 ports: vec![
1052 DockerComposeServicePort {
1053 target: "5443".to_string(),
1054 published: "5442".to_string(),
1055 ..Default::default()
1056 },
1057 DockerComposeServicePort {
1058 target: "8081".to_string(),
1059 published: "8083".to_string(),
1060 mode: Some("host".to_string()),
1061 protocol: Some("udp".to_string()),
1062 host_ip: Some("127.0.0.1".to_string()),
1063 app_protocol: Some("http".to_string()),
1064 name: Some("custom port".to_string()),
1065 },
1066 ],
1067 ..Default::default()
1068 },
1069 ),
1070 (
1071 "db".to_string(),
1072 DockerComposeService {
1073 image: Some("postgres:14.1".to_string()),
1074 volumes: vec![MountDefinition {
1075 mount_type: Some("volume".to_string()),
1076 source: Some("postgres-data".to_string()),
1077 target: "/var/lib/postgresql/data".to_string(),
1078 }],
1079 ..Default::default()
1080 },
1081 ),
1082 ]),
1083 volumes: HashMap::from([(
1084 "postgres-data".to_string(),
1085 DockerComposeVolume {
1086 name: "devcontainer_postgres-data".to_string(),
1087 },
1088 )]),
1089 };
1090
1091 assert_eq!(docker_compose_config, expected_config);
1092 }
1093
1094 #[test]
1095 fn should_deserialize_compose_labels_as_map() {
1096 let given_config = r#"
1097 {
1098 "name": "devcontainer",
1099 "services": {
1100 "app": {
1101 "image": "node:22-alpine",
1102 "volumes": [],
1103 "labels": {
1104 "com.example.test": "value",
1105 "another.label": "another-value"
1106 }
1107 }
1108 }
1109 }
1110 "#;
1111
1112 let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1113 let service = config.services.get("app").unwrap();
1114 let labels = service.labels.clone().unwrap();
1115 assert_eq!(
1116 labels,
1117 HashMap::from([
1118 ("another.label".to_string(), "another-value".to_string()),
1119 ("com.example.test".to_string(), "value".to_string())
1120 ])
1121 );
1122 }
1123
1124 #[test]
1125 fn should_deserialize_compose_labels_as_array() {
1126 let given_config = r#"
1127 {
1128 "name": "devcontainer",
1129 "services": {
1130 "app": {
1131 "image": "node:22-alpine",
1132 "volumes": [],
1133 "labels": ["com.example.test=value"]
1134 }
1135 }
1136 }
1137 "#;
1138
1139 let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1140 let service = config.services.get("app").unwrap();
1141 assert_eq!(
1142 service.labels,
1143 Some(HashMap::from([(
1144 "com.example.test".to_string(),
1145 "value".to_string()
1146 )]))
1147 );
1148 }
1149
1150 #[test]
1151 fn should_deserialize_compose_without_volumes() {
1152 let given_config = r#"
1153 {
1154 "name": "devcontainer",
1155 "services": {
1156 "app": {
1157 "image": "node:22-alpine",
1158 "volumes": []
1159 }
1160 }
1161 }
1162 "#;
1163
1164 let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1165 assert!(config.volumes.is_empty());
1166 }
1167
1168 #[test]
1169 fn should_deserialize_compose_with_missing_volumes_field() {
1170 let given_config = r#"
1171 {
1172 "name": "devcontainer",
1173 "services": {
1174 "sidecar": {
1175 "image": "ubuntu:24.04"
1176 }
1177 }
1178 }
1179 "#;
1180
1181 let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1182 let service = config.services.get("sidecar").unwrap();
1183 assert!(service.volumes.is_empty());
1184 }
1185
1186 #[test]
1187 fn should_deserialize_compose_volume_without_source() {
1188 let given_config = r#"
1189 {
1190 "name": "devcontainer",
1191 "services": {
1192 "app": {
1193 "image": "ubuntu:24.04",
1194 "volumes": [
1195 {
1196 "type": "tmpfs",
1197 "target": "/tmp"
1198 }
1199 ]
1200 }
1201 }
1202 }
1203 "#;
1204
1205 let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1206 let service = config.services.get("app").unwrap();
1207 assert_eq!(service.volumes.len(), 1);
1208 assert_eq!(service.volumes[0].source, None);
1209 assert_eq!(service.volumes[0].target, "/tmp");
1210 assert_eq!(service.volumes[0].mount_type, Some("tmpfs".to_string()));
1211 }
1212
1213 #[test]
1214 fn should_deserialize_inspect_without_labels() {
1215 let given_config = r#"
1216 {
1217 "Id": "sha256:abc123",
1218 "Config": {
1219 "Env": ["PATH=/usr/bin"],
1220 "Cmd": ["node"],
1221 "WorkingDir": "/"
1222 }
1223 }
1224 "#;
1225
1226 let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1227 assert!(inspect.config.labels.metadata.is_none());
1228 assert!(inspect.config.image_user.is_none());
1229 }
1230
1231 #[test]
1232 fn should_deserialize_inspect_with_null_labels() {
1233 let given_config = r#"
1234 {
1235 "Id": "sha256:abc123",
1236 "Config": {
1237 "Labels": null,
1238 "Env": ["PATH=/usr/bin"]
1239 }
1240 }
1241 "#;
1242
1243 let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1244 assert!(inspect.config.labels.metadata.is_none());
1245 }
1246
1247 #[test]
1248 fn should_deserialize_inspect_with_labels_but_no_metadata() {
1249 let given_config = r#"
1250 {
1251 "Id": "sha256:abc123",
1252 "Config": {
1253 "Labels": {
1254 "com.example.test": "value"
1255 },
1256 "Env": ["PATH=/usr/bin"]
1257 }
1258 }
1259 "#;
1260
1261 let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1262 assert!(inspect.config.labels.metadata.is_none());
1263 }
1264}