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