docker.rs

  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}