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