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