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. Load more
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…",