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