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