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