docker.rs

   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}