1493 lines · 52.5 KB · Rust
RawDownload
use std::{collections::HashMap, path::PathBuf}; use async_trait::async_trait;use serde::{Deserialize, Deserializer, Serialize, de};use util::command::Command; use crate::{    command_json::{evaluate_json_command, evaluate_yaml_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,    #[serde(default)]    pub(crate) started_at: Option<String>,} #[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, Default)]pub(crate) struct DockerConfigLabels {    #[serde(        default,        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 {    #[serde(default, deserialize_with = "deserialize_nullable_labels")]    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 Some((key, value)) = env_var.split_once('=') else {                log::warn!("Skipping environment variable without a value: {env_var}");                continue;            };            map.insert(key.to_string(), value.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) target: 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 DockerComposeServicePort {    #[serde(deserialize_with = "deserialize_string_or_int")]    pub(crate) target: String,    #[serde(deserialize_with = "deserialize_string_or_int")]    pub(crate) published: String,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) mode: Option<String>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) protocol: Option<String>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) host_ip: Option<String>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) app_protocol: Option<String>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) name: Option<String>,} fn deserialize_string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>where    D: serde::Deserializer<'de>,{    use serde::Deserialize;     #[derive(Deserialize)]    #[serde(untagged)]    enum StringOrInt {        String(String),        Int(u32),    }     match StringOrInt::deserialize(deserializer)? {        StringOrInt::String(s) => Ok(s),        StringOrInt::Int(b) => Ok(b.to_string()),    }} #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]pub(crate) struct DockerComposeService {    #[serde(skip_serializing_if = "Option::is_none")]    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",        default,        deserialize_with = "deserialize_labels"    )]    pub(crate) labels: Option<HashMap<String, 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>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) init: Option<bool>,    #[serde(        default,        skip_serializing_if = "Vec::is_empty",        deserialize_with = "deserialize_compose_volumes"    )]    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<DockerComposeServicePort>,    #[serde(skip_serializing_if = "Option::is_none")]    pub(crate) network_mode: Option<String>,    #[serde(        default,        skip_serializing_if = "Vec::is_empty",        deserialize_with = "deserialize_nullable_vec"    )]    pub(crate) command: Vec<String>,    #[serde(        skip_serializing_if = "Option::is_none",        default,        deserialize_with = "deserialize_environment"    )]    pub(crate) environment: Option<HashMap<String, String>>,} #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]pub(crate) struct DockerComposeVolume {    #[serde(default, skip_serializing_if = "Option::is_none")]    pub(crate) name: Option<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>,    #[serde(default, deserialize_with = "deserialize_compose_top_level_volumes")]    pub(crate) volumes: HashMap<String, DockerComposeVolume>,} pub(crate) struct Docker {    docker_cli: String,    has_buildx: bool,} impl DockerInspect {    pub(crate) fn is_running(&self) -> bool {        self.state.as_ref().map_or(false, |s| s.running)    }} impl Docker {    pub(crate) async fn new(docker_cli: &str, use_buildkit: Option<bool>) -> Self {        let has_buildx = if docker_cli == "podman" {            false        } else if let Some(use_buildkit) = use_buildkit {            // Honor the explicit `dev_container_use_buildkit` setting. Setting it            // to `false` forces the classic Docker builder for Docker-compatible            // engines that lack an integrated BuildKit (e.g. Apple Container via            // a Docker-API bridge), where BuildKit builds cannot resolve            // locally-built images. The classic builder builds the feature            // content as an image and references it with an ordinary            // multi-stage `FROM`.            use_buildkit        } else {            let output = Command::new(docker_cli)                .args(["buildx", "version"])                .output()                .await;            output.map(|o| o.status.success()).unwrap_or(false)        };        if !has_buildx && docker_cli != "podman" {            log::info!(                "Using the classic Docker builder for dev container builds (BuildKit unavailable or disabled)"            );        }        Self {            docker_cli: docker_cli.to_string(),            has_buildx,        }    }     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.arg("config");        command    }} #[async_trait]impl DockerClient for Docker {    async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError> {        // Always try inspect first — avoid pulling unless necessary.        let command = self.create_docker_inspect(id);        match evaluate_json_command::<DockerInspect>(command).await {            Ok(Some(docker_inspect)) => return Ok(docker_inspect),            Ok(None) | Err(_) => {}        }         // Inspect failed — try pulling and retry.        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_yaml_command(command).await    }     async fn docker_compose_build(        &self,        config_files: &Vec<PathBuf>,        project_name: &str,        services: Option<&Vec<String>>,    ) -> Result<(), DevContainerError> {        let mut command = Command::new(&self.docker_cli);        if !self.is_podman() {            if self.has_buildx {                command.env("DOCKER_BUILDKIT", "1");            } else {                // Without a usable BuildKit, build through the classic builder so                // multi-stage `FROM` of locally-built images (the feature content                // image) resolves from the daemon's image store.                command.env("DOCKER_BUILDKIT", "0");                command.env("COMPOSE_DOCKER_CLI_BUILD", "0");            }        }        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");        if let Some(services) = services {            command.args(services);        }         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())        })?;        let std_out = String::from_utf8_lossy(&output.stdout);        log::debug!("Command output:\n {std_out}");        if !output.status.success() {            let std_err = String::from_utf8_lossy(&output.stderr);            log::error!("Command produced a non-successful output. StdErr: {std_err}");            return Err(DevContainerError::DevContainerScriptsFailed);        }         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 mut command = self.create_docker_query_containers(filters);        let output = command.output().await.map_err(|e| {            log::error!("Error running command {:?}: {e}", command);            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 ps: {stderr}");            return Err(DevContainerError::CommandFailed(                command.get_program().display().to_string(),            ));        }        let raw = String::from_utf8_lossy(&output.stdout);        parse_find_process_output(&raw).map_err(|e| {            // Preserve the dedicated multi-match error; log and re-wrap other parse failures.            if let DevContainerError::MultipleMatchingContainers(_) = &e {                e            } else {                log::error!("Error parsing docker ps output: {e}");                DevContainerError::CommandFailed(command.get_program().display().to_string())            }        })    }     fn docker_cli(&self) -> String {        self.docker_cli.clone()    }     fn supports_compose_buildkit(&self) -> bool {        self.has_buildx    }} /// Parses output of `docker ps -a --format={{ json . }}`. When a single/// container matches the label filters, docker emits one JSON object; when/// multiple match, it emits newline-delimited JSON (one object per line).////// Returns `Ok(None)` for no matches, `Ok(Some(_))` for exactly one match,/// and `DevContainerError::MultipleMatchingContainers` for ≥2 matches — the/// spec expects identifying labels to be unique per project, so the caller/// can't silently pick one.fn parse_find_process_output(raw: &str) -> Result<Option<DockerPs>, DevContainerError> {    if raw.trim().is_empty() {        return Ok(None);    }    let containers: Vec<DockerPs> = serde_json_lenient::Deserializer::from_str(raw)        .into_iter::<DockerPs>()        .collect::<Result<_, _>>()        .map_err(|e| {            DevContainerError::CommandFailed(format!("failed to parse docker ps output: {e}"))        })?;    match containers.len() {        0 => Ok(None),        1 => Ok(containers.into_iter().next()),        _ => Err(DevContainerError::MultipleMatchingContainers(            containers.into_iter().map(|c| c.id).collect(),        )),    }} #[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,        services: Option<&Vec<String>>,    ) -> Result<(), DevContainerError>;    async fn run_docker_exec(

Lines 1–500 of 1493.