898 lines · 31.5 KB · Rust
RawDownload
use std::{collections::HashMap, path::PathBuf}; use async_trait::async_trait;use serde::{Deserialize, Deserializer, Serialize};use util::command::Command; use crate::{    command_json::evaluate_json_command, devcontainer_api::DevContainerError,    devcontainer_json::MountDefinition,}; #[derive(Debug, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "PascalCase")]pub(crate) struct DockerPs {    #[serde(alias = "ID")]    pub(crate) id: String,} #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "PascalCase")]pub(crate) struct DockerState {    pub(crate) running: bool,} #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "PascalCase")]pub(crate) struct DockerInspect {    pub(crate) id: String,    pub(crate) config: DockerInspectConfig,    pub(crate) mounts: Option<Vec<DockerInspectMount>>,    pub(crate) state: Option<DockerState>,} #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]pub(crate) struct DockerConfigLabels {    #[serde(        rename = "devcontainer.metadata",        deserialize_with = "deserialize_metadata"    )]    pub(crate) metadata: Option<Vec<HashMap<String, serde_json_lenient::Value>>>,} #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "PascalCase")]pub(crate) struct DockerInspectConfig {    pub(crate) labels: DockerConfigLabels,    #[serde(rename = "User")]    pub(crate) image_user: Option<String>,    #[serde(default)]    pub(crate) env: Vec<String>,} impl DockerInspectConfig {    pub(crate) fn env_as_map(&self) -> Result<HashMap<String, String>, DevContainerError> {        let mut map = HashMap::new();        for env_var in &self.env {            let parts: Vec<&str> = env_var.split("=").collect();            if parts.len() != 2 {                log::error!("Unable to parse {env_var} into and environment key-value");                return Err(DevContainerError::DevContainerParseFailed);            }            map.insert(parts[0].to_string(), parts[1].to_string());        }        Ok(map)    }} #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]#[serde(rename_all = "PascalCase")]pub(crate) struct DockerInspectMount {    pub(crate) source: String,    pub(crate) destination: String,} #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]pub(crate) struct DockerComposeServiceBuild {    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) context: Option<String>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) dockerfile: Option<String>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) args: Option<HashMap<String, String>>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) additional_contexts: Option<HashMap<String, String>>,} #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]pub(crate) struct DockerComposeService {    pub(crate) image: Option<String>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) entrypoint: Option<Vec<String>>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) cap_add: Option<Vec<String>>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) security_opt: Option<Vec<String>>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) labels: Option<Vec<String>>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) build: Option<DockerComposeServiceBuild>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) privileged: Option<bool>,    pub(crate) volumes: Vec<MountDefinition>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) env_file: Option<Vec<String>>,    #[serde(default, skip_serializing_if = "Vec::is_empty")]    pub(crate) ports: Vec<String>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) network_mode: Option<String>,} #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]pub(crate) struct DockerComposeVolume {    pub(crate) name: String,} #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]pub(crate) struct DockerComposeConfig {    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) name: Option<String>,    pub(crate) services: HashMap<String, DockerComposeService>,    pub(crate) volumes: HashMap<String, DockerComposeVolume>,} pub(crate) struct Docker {    docker_cli: String,} impl DockerInspect {    pub(crate) fn is_running(&self) -> bool {        self.state.as_ref().map_or(false, |s| s.running)    }} impl Docker {    pub(crate) fn new(docker_cli: &str) -> Self {        Self {            docker_cli: docker_cli.to_string(),        }    }     fn is_podman(&self) -> bool {        self.docker_cli == "podman"    }     async fn pull_image(&self, image: &String) -> Result<(), DevContainerError> {        let mut command = Command::new(&self.docker_cli);        command.args(&["pull", image]);         let output = command.output().await.map_err(|e| {            log::error!("Error pulling image: {e}");            DevContainerError::ResourceFetchFailed        })?;         if !output.status.success() {            let stderr = String::from_utf8_lossy(&output.stderr);            log::error!("Non-success result from docker pull: {stderr}");            return Err(DevContainerError::ResourceFetchFailed);        }        Ok(())    }     fn create_docker_query_containers(&self, filters: Vec<String>) -> Command {        let mut command = Command::new(&self.docker_cli);        command.args(&["ps", "-a"]);         for filter in filters {            command.arg("--filter");            command.arg(filter);        }        command.arg("--format={{ json . }}");        command    }     fn create_docker_inspect(&self, id: &str) -> Command {        let mut command = Command::new(&self.docker_cli);        command.args(&["inspect", "--format={{json . }}", id]);        command    }     fn create_docker_compose_config_command(&self, config_files: &Vec<PathBuf>) -> Command {        let mut command = Command::new(&self.docker_cli);        command.arg("compose");        for file_path in config_files {            command.args(&["-f", &file_path.display().to_string()]);        }        command.args(&["config", "--format", "json"]);        command    }} #[async_trait]impl DockerClient for Docker {    async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError> {        // Try to pull the image, continue on failure; Image may be local only, id a reference to a running container        self.pull_image(id).await.ok();         let command = self.create_docker_inspect(id);         let Some(docker_inspect): Option<DockerInspect> = evaluate_json_command(command).await?        else {            log::error!("Docker inspect produced no deserializable output");            return Err(DevContainerError::CommandFailed(self.docker_cli.clone()));        };        Ok(docker_inspect)    }     async fn get_docker_compose_config(        &self,        config_files: &Vec<PathBuf>,    ) -> Result<Option<DockerComposeConfig>, DevContainerError> {        let command = self.create_docker_compose_config_command(config_files);        evaluate_json_command(command).await    }     async fn docker_compose_build(        &self,        config_files: &Vec<PathBuf>,        project_name: &str,    ) -> Result<(), DevContainerError> {        let mut command = Command::new(&self.docker_cli);        if !self.is_podman() {            command.env("DOCKER_BUILDKIT", "1");        }        command.args(&["compose", "--project-name", project_name]);        for docker_compose_file in config_files {            command.args(&["-f", &docker_compose_file.display().to_string()]);        }        command.arg("build");         let output = command.output().await.map_err(|e| {            log::error!("Error running docker compose up: {e}");            DevContainerError::CommandFailed(command.get_program().display().to_string())        })?;         if !output.status.success() {            let stderr = String::from_utf8_lossy(&output.stderr);            log::error!("Non-success status from docker compose up: {}", stderr);            return Err(DevContainerError::CommandFailed(                command.get_program().display().to_string(),            ));        }         Ok(())    }    async fn run_docker_exec(        &self,        container_id: &str,        remote_folder: &str,        user: &str,        env: &HashMap<String, String>,        inner_command: Command,    ) -> Result<(), DevContainerError> {        let mut command = Command::new(&self.docker_cli);         command.args(&["exec", "-w", remote_folder, "-u", user]);         for (k, v) in env.iter() {            command.arg("-e");            let env_declaration = format!("{}={}", k, v);            command.arg(&env_declaration);        }         command.arg(container_id);         command.arg("sh");         let mut inner_program_script: Vec<String> =            vec![inner_command.get_program().display().to_string()];        let mut args: Vec<String> = inner_command            .get_args()            .map(|arg| arg.display().to_string())            .collect();        inner_program_script.append(&mut args);        command.args(&["-c", &inner_program_script.join(" ")]);         let output = command.output().await.map_err(|e| {            log::error!("Error running command {e} in container exec");            DevContainerError::ContainerNotValid(container_id.to_string())        })?;        if !output.status.success() {            let std_err = String::from_utf8_lossy(&output.stderr);            log::error!("Command produced a non-successful output. StdErr: {std_err}");        }        let std_out = String::from_utf8_lossy(&output.stdout);        log::debug!("Command output:\n {std_out}");         Ok(())    }    async fn start_container(&self, id: &str) -> Result<(), DevContainerError> {        let mut command = Command::new(&self.docker_cli);         command.args(&["start", id]);         let output = command.output().await.map_err(|e| {            log::error!("Error running docker start: {e}");            DevContainerError::CommandFailed(command.get_program().display().to_string())        })?;         if !output.status.success() {            let stderr = String::from_utf8_lossy(&output.stderr);            log::error!("Non-success status from docker start: {stderr}");            return Err(DevContainerError::CommandFailed(                command.get_program().display().to_string(),            ));        }         Ok(())    }     async fn find_process_by_filters(        &self,        filters: Vec<String>,    ) -> Result<Option<DockerPs>, DevContainerError> {        let command = self.create_docker_query_containers(filters);        evaluate_json_command(command).await    }     fn docker_cli(&self) -> String {        self.docker_cli.clone()    }     fn supports_compose_buildkit(&self) -> bool {        !self.is_podman()    }} #[async_trait]pub(crate) trait DockerClient {    async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError>;    async fn get_docker_compose_config(        &self,        config_files: &Vec<PathBuf>,    ) -> Result<Option<DockerComposeConfig>, DevContainerError>;    async fn docker_compose_build(        &self,        config_files: &Vec<PathBuf>,        project_name: &str,    ) -> Result<(), DevContainerError>;    async fn run_docker_exec(        &self,        container_id: &str,        remote_folder: &str,        user: &str,        env: &HashMap<String, String>,        inner_command: Command,    ) -> Result<(), DevContainerError>;    async fn start_container(&self, id: &str) -> Result<(), DevContainerError>;    async fn find_process_by_filters(        &self,        filters: Vec<String>,    ) -> Result<Option<DockerPs>, DevContainerError>;    fn supports_compose_buildkit(&self) -> bool;    /// This operates as an escape hatch for more custom uses of the docker API.    /// See DevContainerManifest::create_docker_build as an example    fn docker_cli(&self) -> String;} fn deserialize_metadata<'de, D>(    deserializer: D,) -> Result<Option<Vec<HashMap<String, serde_json_lenient::Value>>>, D::Error>where    D: Deserializer<'de>,{    let s: Option<String> = Option::deserialize(deserializer)?;    match s {        Some(json_string) => {            let parsed: Vec<HashMap<String, serde_json_lenient::Value>> =                serde_json_lenient::from_str(&json_string).map_err(|e| {                    log::error!("Error deserializing metadata: {e}");                    serde::de::Error::custom(e)                })?;            Ok(Some(parsed))        }        None => Ok(None),    }} pub(crate) fn get_remote_dir_from_config(    config: &DockerInspect,    local_dir: String,) -> Result<String, DevContainerError> {    let local_path = PathBuf::from(&local_dir);     let Some(mounts) = &config.mounts else {        log::error!("No mounts defined for container");        return Err(DevContainerError::ContainerNotValid(config.id.clone()));    };     for mount in mounts {        // Sometimes docker will mount the local filesystem on host_mnt for system isolation        let mount_source = PathBuf::from(&mount.source.trim_start_matches("/host_mnt"));        if let Ok(relative_path_to_project) = local_path.strip_prefix(&mount_source) {            let remote_dir = format!(                "{}/{}",                &mount.destination,                relative_path_to_project.display()            );            return Ok(remote_dir);        }        if mount.source == local_dir {            return Ok(mount.destination.clone());        }    }    log::error!("No mounts to local folder");    Err(DevContainerError::ContainerNotValid(config.id.clone()))} #[cfg(test)]mod test {    use std::{        collections::HashMap,        ffi::OsStr,        process::{ExitStatus, Output},    };     use crate::{        command_json::deserialize_json_output,        devcontainer_json::MountDefinition,        docker::{            Docker, DockerComposeConfig, DockerComposeService, DockerComposeVolume, DockerInspect,            DockerPs, get_remote_dir_from_config,        },    };     #[test]    fn should_create_docker_inspect_command() {        let docker = Docker::new("docker");        let given_id = "given_docker_id";         let command = docker.create_docker_inspect(given_id);         assert_eq!(            command.get_args().collect::<Vec<&OsStr>>(),            vec![                OsStr::new("inspect"),                OsStr::new("--format={{json . }}"),                OsStr::new(given_id)            ]        )    }     #[test]    fn should_deserialize_docker_ps_with_filters() {        // First, deserializes empty        let empty_output = Output {            status: ExitStatus::default(),            stderr: vec![],            stdout: String::from("").into_bytes(),        };         let result: Option<DockerPs> = deserialize_json_output(empty_output).unwrap();         assert!(result.is_none());         let full_output = Output {                status: ExitStatus::default(),                stderr: vec![],                stdout: String::from(r#"    {        "Command": "\"/bin/sh -c 'echo Co…\"",        "CreatedAt": "2026-02-04 15:44:21 -0800 PST",        "ID": "abdb6ab59573",        "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",        "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",        "LocalVolumes": "0",        "Mounts": "/host_mnt/User…",        "Names": "objective_haslett",        "Networks": "bridge",        "Platform": {        "architecture": "arm64",        "os": "linux"        },        "Ports": "",        "RunningFor": "47 hours ago",        "Size": "0B",        "State": "running",        "Status": "Up 47 hours"    }                    "#).into_bytes(),            };         let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();         assert!(result.is_some());        let result = result.unwrap();        assert_eq!(result.id, "abdb6ab59573".to_string());         // Podman variant (Id, not ID)        let full_output = Output {                status: ExitStatus::default(),                stderr: vec![],                stdout: String::from(r#"    {        "Command": "\"/bin/sh -c 'echo Co…\"",        "CreatedAt": "2026-02-04 15:44:21 -0800 PST",        "Id": "abdb6ab59573",        "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",        "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",        "LocalVolumes": "0",        "Mounts": "/host_mnt/User…",

Lines 1–500 of 898.