From 3eadd41b5dc8b870d0524718c0ecd458fd2a523d Mon Sep 17 00:00:00 2001 From: KyleBarton Date: Wed, 1 Apr 2026 08:16:27 -0700 Subject: [PATCH] Dev containers native implementation (#52338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Closes #11473 In-house Zed implementation of devcontainers. Replaces the dependency on the [reference implementation](https://github.com/devcontainers/cli) via Node. This enables additional features with this implementation: 1. Zed extensions can be specified in the `customizations` block, via this syntax in `devcontainer.json: ``` ... "customizations": { "zed": { "extensions": ["vue", "ruby"], }, }, ``` 2. [forwardPorts](https://containers.dev/implementors/json_reference/#general-properties) are supported for multiple ports proxied to the host ## How to Review ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Improved devcontainer implementation by moving initialization and creation in-house --- Cargo.lock | 8 +- crates/dev_container/Cargo.toml | 13 +- crates/dev_container/src/command_json.rs | 64 + crates/dev_container/src/devcontainer_api.rs | 586 +- crates/dev_container/src/devcontainer_json.rs | 1358 ++++ .../src/devcontainer_manifest.rs | 6571 +++++++++++++++++ crates/dev_container/src/docker.rs | 898 +++ crates/dev_container/src/features.rs | 254 + crates/dev_container/src/lib.rs | 413 +- crates/dev_container/src/oci.rs | 470 ++ crates/recent_projects/src/recent_projects.rs | 9 +- .../recent_projects/src/remote_connections.rs | 1 + crates/recent_projects/src/remote_servers.rs | 23 +- crates/remote/src/transport/docker.rs | 22 +- .../settings_content/src/settings_content.rs | 5 +- crates/util/src/command.rs | 8 + crates/util/src/command/darwin.rs | 8 + crates/workspace/src/persistence.rs | 27 +- 18 files changed, 10076 insertions(+), 662 deletions(-) create mode 100644 crates/dev_container/src/command_json.rs create mode 100644 crates/dev_container/src/devcontainer_json.rs create mode 100644 crates/dev_container/src/devcontainer_manifest.rs create mode 100644 crates/dev_container/src/docker.rs create mode 100644 crates/dev_container/src/features.rs create mode 100644 crates/dev_container/src/oci.rs diff --git a/Cargo.lock b/Cargo.lock index bfd80726843695dbfcb4baf1db4fe3e6ca9a4682..0cf311de4d45c4b666265919b41d6f7cb45176cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4729,6 +4729,9 @@ dependencies = [ name = "dev_container" version = "0.1.0" dependencies = [ + "async-tar", + "async-trait", + "env_logger 0.11.8", "fs", "futures 0.3.31", "gpui", @@ -4736,16 +4739,17 @@ dependencies = [ "http_client", "log", "menu", - "node_runtime", "paths", "picker", "project", "serde", "serde_json", + "serde_json_lenient", "settings", - "smol", + "shlex", "ui", "util", + "walkdir", "workspace", "worktree", ] diff --git a/crates/dev_container/Cargo.toml b/crates/dev_container/Cargo.toml index e3a67601c3837bd9579a477576e9c837f73c1e75..e04b965b076fe1ba6c5a8f47e548b922dab55d4a 100644 --- a/crates/dev_container/Cargo.toml +++ b/crates/dev_container/Cargo.toml @@ -5,21 +5,26 @@ publish.workspace = true edition.workspace = true [dependencies] +async-tar.workspace = true +async-trait.workspace = true serde.workspace = true serde_json.workspace = true +serde_json_lenient.workspace = true +shlex.workspace = true http_client.workspace = true http.workspace = true gpui.workspace = true +fs.workspace = true futures.workspace = true log.workspace = true -node_runtime.workspace = true menu.workspace = true paths.workspace = true picker.workspace = true +project.workspace = true settings.workspace = true -smol.workspace = true ui.workspace = true util.workspace = true +walkdir.workspace = true worktree.workspace = true workspace.workspace = true @@ -32,6 +37,8 @@ settings = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } worktree = { workspace = true, features = ["test-support"] } +util = { workspace = true, features = ["test-support"] } +env_logger.workspace = true [lints] -workspace = true \ No newline at end of file +workspace = true diff --git a/crates/dev_container/src/command_json.rs b/crates/dev_container/src/command_json.rs new file mode 100644 index 0000000000000000000000000000000000000000..9823fec4068f141efb4e306fa455bbb7b29a678e --- /dev/null +++ b/crates/dev_container/src/command_json.rs @@ -0,0 +1,64 @@ +use std::process::Output; + +use async_trait::async_trait; +use serde::Deserialize; +use util::command::Command; + +use crate::devcontainer_api::DevContainerError; + +pub(crate) struct DefaultCommandRunner; + +impl DefaultCommandRunner { + pub(crate) fn new() -> Self { + Self + } +} + +#[async_trait] +impl CommandRunner for DefaultCommandRunner { + async fn run_command(&self, command: &mut Command) -> Result { + command.output().await + } +} + +#[async_trait] +pub(crate) trait CommandRunner: Send + Sync { + async fn run_command(&self, command: &mut Command) -> Result; +} + +pub(crate) async fn evaluate_json_command( + mut command: Command, +) -> Result, DevContainerError> +where + T: for<'de> Deserialize<'de>, +{ + let output = command.output().await.map_err(|e| { + log::error!("Error running command {:?}: {e}", command); + DevContainerError::CommandFailed(command.get_program().display().to_string()) + })?; + + deserialize_json_output(output).map_err(|e| { + log::error!("Error running command {:?}: {e}", command); + DevContainerError::CommandFailed(command.get_program().display().to_string()) + }) +} + +pub(crate) fn deserialize_json_output(output: Output) -> Result, String> +where + T: for<'de> Deserialize<'de>, +{ + if output.status.success() { + let raw = String::from_utf8_lossy(&output.stdout); + if raw.is_empty() || raw.trim() == "[]" || raw.trim() == "{}" { + return Ok(None); + } + let value = serde_json_lenient::from_str(&raw) + .map_err(|e| format!("Error deserializing from raw json: {e}")); + value + } else { + let std_err = String::from_utf8_lossy(&output.stderr); + Err(format!( + "Sent non-successful output; cannot deserialize. StdErr: {std_err}" + )) + } +} diff --git a/crates/dev_container/src/devcontainer_api.rs b/crates/dev_container/src/devcontainer_api.rs index 15c39dde119be04e5c58f34f268a98935954d6fe..f9f0136fcfc5ffe29c643acb0371b89107ab3d47 100644 --- a/crates/dev_container/src/devcontainer_api.rs +++ b/crates/dev_container/src/devcontainer_api.rs @@ -2,18 +2,26 @@ use std::{ collections::{HashMap, HashSet}, fmt::Display, path::{Path, PathBuf}, + sync::Arc, }; -use node_runtime::NodeRuntime; +use futures::TryFutureExt; +use gpui::{AsyncWindowContext, Entity}; +use project::Worktree; use serde::Deserialize; -use settings::DevContainerConnection; -use smol::fs; -use util::command::Command; +use settings::{DevContainerConnection, infer_json_indent_size, replace_value_in_json_text}; use util::rel_path::RelPath; +use walkdir::WalkDir; use workspace::Workspace; use worktree::Snapshot; -use crate::{DevContainerContext, DevContainerFeature, DevContainerTemplate}; +use crate::{ + DevContainerContext, DevContainerFeature, DevContainerTemplate, + devcontainer_json::DevContainer, + devcontainer_manifest::{read_devcontainer_configuration, spawn_dev_container}, + devcontainer_templates_repository, get_latest_oci_manifest, get_oci_token, ghcr_registry, + oci::download_oci_tarball, +}; /// Represents a discovered devcontainer configuration #[derive(Debug, Clone, PartialEq, Eq)] @@ -42,63 +50,33 @@ impl DevContainerConfig { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct DevContainerUp { - _outcome: String, - container_id: String, - remote_user: String, - remote_workspace_folder: String, +pub(crate) struct DevContainerUp { + pub(crate) container_id: String, + pub(crate) remote_user: String, + pub(crate) remote_workspace_folder: String, + #[serde(default)] + pub(crate) extension_ids: Vec, + #[serde(default)] + pub(crate) remote_env: HashMap, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug)] pub(crate) struct DevContainerApply { - pub(crate) files: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DevContainerConfiguration { - name: Option, -} - -#[derive(Debug, Deserialize)] -pub(crate) struct DevContainerConfigurationOutput { - configuration: DevContainerConfiguration, -} - -pub(crate) struct DevContainerCli { - pub path: PathBuf, - node_runtime_path: Option, -} - -impl DevContainerCli { - fn command(&self, use_podman: bool) -> Command { - let mut command = if let Some(node_runtime_path) = &self.node_runtime_path { - let mut command = - util::command::new_command(node_runtime_path.as_os_str().display().to_string()); - command.arg(self.path.display().to_string()); - command - } else { - util::command::new_command(self.path.display().to_string()) - }; - - if use_podman { - command.arg("--docker-path"); - command.arg("podman"); - } - command - } + pub(crate) project_files: Vec>, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum DevContainerError { + CommandFailed(String), DockerNotAvailable, - DevContainerCliNotAvailable, + ContainerNotValid(String), DevContainerTemplateApplyFailed(String), + DevContainerScriptsFailed, DevContainerUpFailed(String), DevContainerNotFound, DevContainerParseFailed, - NodeRuntimeNotAvailable, + FilesystemError, + ResourceFetchFailed, NotInValidProject, } @@ -110,8 +88,11 @@ impl Display for DevContainerError { match self { DevContainerError::DockerNotAvailable => "docker CLI not found on $PATH".to_string(), - DevContainerError::DevContainerCliNotAvailable => - "devcontainer CLI not found on path".to_string(), + DevContainerError::ContainerNotValid(id) => format!( + "docker image {id} did not have expected configuration for a dev container" + ), + DevContainerError::DevContainerScriptsFailed => + "lifecycle scripts could not execute for dev container".to_string(), DevContainerError::DevContainerUpFailed(_) => { "DevContainer creation failed".to_string() } @@ -122,14 +103,32 @@ impl Display for DevContainerError { "No valid dev container definition found in project".to_string(), DevContainerError::DevContainerParseFailed => "Failed to parse file .devcontainer/devcontainer.json".to_string(), - DevContainerError::NodeRuntimeNotAvailable => - "Cannot find a valid node runtime".to_string(), DevContainerError::NotInValidProject => "Not within a valid project".to_string(), + DevContainerError::CommandFailed(program) => + format!("Failure running external program {program}"), + DevContainerError::FilesystemError => + "Error downloading resources locally".to_string(), + DevContainerError::ResourceFetchFailed => + "Failed to fetch resources from template or feature repository".to_string(), } ) } } +pub(crate) async fn read_default_devcontainer_configuration( + cx: &DevContainerContext, + environment: HashMap, +) -> Result { + let default_config = DevContainerConfig::default_config(); + + read_devcontainer_configuration(default_config, cx, environment) + .await + .map_err(|e| { + log::error!("Default configuration not found: {:?}", e); + DevContainerError::DevContainerNotFound + }) +} + /// Finds all available devcontainer configurations in the project. /// /// See [`find_configs_in_snapshot`] for the locations that are scanned. @@ -241,27 +240,35 @@ pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec pub async fn start_dev_container_with_config( context: DevContainerContext, config: Option, + environment: HashMap, ) -> Result<(DevContainerConnection, String), DevContainerError> { check_for_docker(context.use_podman).await?; - let cli = ensure_devcontainer_cli(&context.node_runtime).await?; - let config_path = config.map(|c| context.project_directory.join(&c.config_path)); - match devcontainer_up(&context, &cli, config_path.as_deref()).await { + let Some(actual_config) = config.clone() else { + return Err(DevContainerError::NotInValidProject); + }; + + match spawn_dev_container( + &context, + environment.clone(), + actual_config.clone(), + context.project_directory.clone().as_ref(), + ) + .await + { Ok(DevContainerUp { container_id, remote_workspace_folder, remote_user, + extension_ids, + remote_env, .. }) => { let project_name = - match read_devcontainer_configuration(&context, &cli, config_path.as_deref()).await - { - Ok(DevContainerConfigurationOutput { - configuration: - DevContainerConfiguration { - name: Some(project_name), - }, - }) => project_name, + match read_devcontainer_configuration(actual_config, &context, environment).await { + Ok(DevContainer { + name: Some(name), .. + }) => name, _ => get_backup_project_name(&remote_workspace_folder, &container_id), }; @@ -270,31 +277,19 @@ pub async fn start_dev_container_with_config( container_id, use_podman: context.use_podman, remote_user, + extension_ids, + remote_env: remote_env.into_iter().collect(), }; Ok((connection, remote_workspace_folder)) } Err(err) => { - let message = format!("Failed with nested error: {}", err); + let message = format!("Failed with nested error: {:?}", err); Err(DevContainerError::DevContainerUpFailed(message)) } } } -#[cfg(not(target_os = "windows"))] -fn dev_container_cli() -> String { - "devcontainer".to_string() -} - -#[cfg(target_os = "windows")] -fn dev_container_cli() -> String { - "devcontainer.cmd".to_string() -} - -fn dev_container_script() -> String { - "devcontainer.js".to_string() -} - async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> { let mut command = if use_podman { util::command::new_command("podman") @@ -312,261 +307,157 @@ async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> { } } -pub(crate) async fn ensure_devcontainer_cli( - node_runtime: &NodeRuntime, -) -> Result { - let mut command = util::command::new_command(&dev_container_cli()); - command.arg("--version"); - - if let Err(e) = command.output().await { - log::error!( - "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}", - e - ); - - let Ok(node_runtime_path) = node_runtime.binary_path().await else { - return Err(DevContainerError::NodeRuntimeNotAvailable); +pub(crate) async fn apply_devcontainer_template( + worktree: Entity, + template: &DevContainerTemplate, + template_options: &HashMap, + features_selected: &HashSet, + context: &DevContainerContext, + cx: &mut AsyncWindowContext, +) -> Result { + let token = get_oci_token( + ghcr_registry(), + devcontainer_templates_repository(), + &context.http_client, + ) + .map_err(|e| { + log::error!("Failed to get OCI auth token: {e}"); + DevContainerError::ResourceFetchFailed + }) + .await?; + let manifest = get_latest_oci_manifest( + &token.token, + ghcr_registry(), + devcontainer_templates_repository(), + &context.http_client, + Some(&template.id), + ) + .map_err(|e| { + log::error!("Failed to fetch template from OCI repository: {e}"); + DevContainerError::ResourceFetchFailed + }) + .await?; + + let layer = &manifest.layers.get(0).ok_or_else(|| { + log::error!("Given manifest has no layers to query for blob. Aborting"); + DevContainerError::ResourceFetchFailed + })?; + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let extract_dir = std::env::temp_dir() + .join(&template.id) + .join(format!("extracted-{timestamp}")); + + context.fs.create_dir(&extract_dir).await.map_err(|e| { + log::error!("Could not create temporary directory: {e}"); + DevContainerError::FilesystemError + })?; + + download_oci_tarball( + &token.token, + ghcr_registry(), + devcontainer_templates_repository(), + &layer.digest, + "application/vnd.oci.image.manifest.v1+json", + &extract_dir, + &context.http_client, + &context.fs, + Some(&template.id), + ) + .map_err(|e| { + log::error!("Error downloading tarball: {:?}", e); + DevContainerError::ResourceFetchFailed + }) + .await?; + + let downloaded_devcontainer_folder = &extract_dir.join(".devcontainer/"); + let mut project_files = Vec::new(); + for entry in WalkDir::new(downloaded_devcontainer_folder) { + let Ok(entry) = entry else { + continue; }; - - let datadir_cli_path = paths::devcontainer_dir() - .join("node_modules") - .join("@devcontainers") - .join("cli") - .join(&dev_container_script()); - - log::debug!( - "devcontainer not found in path, using local location: ${}", - datadir_cli_path.display() - ); - - let mut command = - util::command::new_command(node_runtime_path.as_os_str().display().to_string()); - command.arg(datadir_cli_path.display().to_string()); - command.arg("--version"); - - match command.output().await { - Err(e) => log::error!( - "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}", - e - ), - Ok(output) => { - if output.status.success() { - log::info!("Found devcontainer CLI in Data dir"); - return Ok(DevContainerCli { - path: datadir_cli_path.clone(), - node_runtime_path: Some(node_runtime_path.clone()), - }); - } else { - log::error!( - "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}", - output - ); - } - } + if !entry.file_type().is_file() { + continue; } - - if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await { - log::error!("Unable to create devcontainer directory. Error: {:?}", e); - return Err(DevContainerError::DevContainerCliNotAvailable); + let relative_path = entry.path().strip_prefix(&extract_dir).map_err(|e| { + log::error!("Can't create relative path: {e}"); + DevContainerError::FilesystemError + })?; + let rel_path = RelPath::unix(relative_path) + .map_err(|e| { + log::error!("Can't create relative path: {e}"); + DevContainerError::FilesystemError + })? + .into_arc(); + let content = context.fs.load(entry.path()).await.map_err(|e| { + log::error!("Unable to read file: {e}"); + DevContainerError::FilesystemError + })?; + + let mut content = expand_template_options(content, template_options); + if let Some("devcontainer.json") = &rel_path.file_name() { + content = insert_features_into_devcontainer_json(&content, features_selected) } - - if let Err(e) = node_runtime - .npm_install_packages( - &paths::devcontainer_dir(), - &[("@devcontainers/cli", "latest")], - ) - .await - { - log::error!( - "Unable to install devcontainer CLI to data directory. Error: {:?}", - e - ); - return Err(DevContainerError::DevContainerCliNotAvailable); - }; - - let mut command = - util::command::new_command(node_runtime_path.as_os_str().display().to_string()); - command.arg(datadir_cli_path.display().to_string()); - command.arg("--version"); - if let Err(e) = command.output().await { - log::error!( - "Unable to find devcontainer cli after NPM install. Error: {:?}", - e - ); - Err(DevContainerError::DevContainerCliNotAvailable) - } else { - Ok(DevContainerCli { - path: datadir_cli_path, - node_runtime_path: Some(node_runtime_path), + worktree + .update(cx, |worktree, cx| { + worktree.create_entry(rel_path.clone(), false, Some(content.into_bytes()), cx) }) - } - } else { - log::info!("Found devcontainer cli on $PATH, using it"); - Ok(DevContainerCli { - path: PathBuf::from(&dev_container_cli()), - node_runtime_path: None, - }) - } -} - -async fn devcontainer_up( - context: &DevContainerContext, - cli: &DevContainerCli, - config_path: Option<&Path>, -) -> Result { - let mut command = cli.command(context.use_podman); - command.arg("up"); - command.arg("--workspace-folder"); - command.arg(context.project_directory.display().to_string()); - - if let Some(config) = config_path { - command.arg("--config"); - command.arg(config.display().to_string()); + .await + .map_err(|e| { + log::error!("Unable to create entry in worktree: {e}"); + DevContainerError::NotInValidProject + })?; + project_files.push(rel_path); } - log::info!("Running full devcontainer up command: {:?}", command); - - match command.output().await { - Ok(output) => { - if output.status.success() { - let raw = String::from_utf8_lossy(&output.stdout); - parse_json_from_cli(&raw) - } else { - let message = format!( - "Non-success status running devcontainer up for workspace: out: {}, err: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - log::error!("{}", &message); - Err(DevContainerError::DevContainerUpFailed(message)) - } - } - Err(e) => { - let message = format!("Error running devcontainer up: {:?}", e); - log::error!("{}", &message); - Err(DevContainerError::DevContainerUpFailed(message)) - } - } + Ok(DevContainerApply { project_files }) } -pub(crate) async fn read_devcontainer_configuration( - context: &DevContainerContext, - cli: &DevContainerCli, - config_path: Option<&Path>, -) -> Result { - let mut command = cli.command(context.use_podman); - command.arg("read-configuration"); - command.arg("--workspace-folder"); - command.arg(context.project_directory.display().to_string()); - - if let Some(config) = config_path { - command.arg("--config"); - command.arg(config.display().to_string()); - } - - match command.output().await { - Ok(output) => { - if output.status.success() { - let raw = String::from_utf8_lossy(&output.stdout); - parse_json_from_cli(&raw) - } else { - let message = format!( - "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - log::error!("{}", &message); - Err(DevContainerError::DevContainerNotFound) - } - } - Err(e) => { - let message = format!("Error running devcontainer read-configuration: {:?}", e); - log::error!("{}", &message); - Err(DevContainerError::DevContainerNotFound) - } +fn insert_features_into_devcontainer_json( + content: &str, + features: &HashSet, +) -> String { + if features.is_empty() { + return content.to_string(); } -} - -pub(crate) async fn apply_dev_container_template( - template: &DevContainerTemplate, - template_options: &HashMap, - features_selected: &HashSet, - context: &DevContainerContext, - cli: &DevContainerCli, -) -> Result { - let mut command = cli.command(context.use_podman); - - let Ok(serialized_options) = serde_json::to_string(template_options) else { - log::error!("Unable to serialize options for {:?}", template_options); - return Err(DevContainerError::DevContainerParseFailed); - }; - command.arg("templates"); - command.arg("apply"); - command.arg("--workspace-folder"); - command.arg(context.project_directory.display().to_string()); - command.arg("--template-id"); - command.arg(format!( - "{}/{}", - template - .source_repository - .as_ref() - .unwrap_or(&String::from("")), - template.id - )); - command.arg("--template-args"); - command.arg(serialized_options); - command.arg("--features"); - command.arg(template_features_to_json(features_selected)); - - log::debug!("Running full devcontainer apply command: {:?}", command); + let features_value: serde_json::Value = features + .iter() + .map(|f| { + let key = format!( + "{}/{}:{}", + f.source_repository.as_deref().unwrap_or(""), + f.id, + f.major_version() + ); + (key, serde_json::Value::Object(Default::default())) + }) + .collect::>() + .into(); + + let tab_size = infer_json_indent_size(content); + let (range, replacement) = replace_value_in_json_text( + content, + &["features"], + tab_size, + Some(&features_value), + None, + ); - match command.output().await { - Ok(output) => { - if output.status.success() { - let raw = String::from_utf8_lossy(&output.stdout); - parse_json_from_cli(&raw) - } else { - let message = format!( - "Non-success status running devcontainer templates apply for workspace: out: {:?}, err: {:?}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); + let mut result = content.to_string(); + result.replace_range(range, &replacement); + result +} - log::error!("{}", &message); - Err(DevContainerError::DevContainerTemplateApplyFailed(message)) - } - } - Err(e) => { - let message = format!("Error running devcontainer templates apply: {:?}", e); - log::error!("{}", &message); - Err(DevContainerError::DevContainerTemplateApplyFailed(message)) - } +fn expand_template_options(content: String, template_options: &HashMap) -> String { + let mut replaced_content = content; + for (key, val) in template_options { + replaced_content = replaced_content.replace(&format!("${{templateOption:{key}}}"), val) } -} -// Try to parse directly first (newer versions output pure JSON) -// If that fails, look for JSON start (older versions have plaintext prefix) -fn parse_json_from_cli(raw: &str) -> Result { - serde_json::from_str::(&raw) - .or_else(|e| { - log::error!("Error parsing json: {} - will try to find json object in larger plaintext", e); - let json_start = raw - .find(|c| c == '{') - .ok_or_else(|| { - log::error!("No JSON found in devcontainer up output"); - DevContainerError::DevContainerParseFailed - })?; - - serde_json::from_str(&raw[json_start..]).map_err(|e| { - log::error!( - "Unable to parse JSON from devcontainer up output (starting at position {}), error: {:?}", - json_start, - e - ); - DevContainerError::DevContainerParseFailed - }) - }) + replaced_content } fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String { @@ -577,36 +468,11 @@ fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> .unwrap_or_else(|| container_id.to_string()) } -fn template_features_to_json(features_selected: &HashSet) -> String { - let features_map = features_selected - .iter() - .map(|feature| { - let mut map = HashMap::new(); - map.insert( - "id", - format!( - "{}/{}:{}", - feature - .source_repository - .as_ref() - .unwrap_or(&String::from("")), - feature.id, - feature.major_version() - ), - ); - map - }) - .collect::>>(); - serde_json::to_string(&features_map).unwrap() -} - #[cfg(test)] mod tests { use std::path::PathBuf; - use crate::devcontainer_api::{ - DevContainerConfig, DevContainerUp, find_configs_in_snapshot, parse_json_from_cli, - }; + use crate::devcontainer_api::{DevContainerConfig, find_configs_in_snapshot}; use fs::FakeFs; use gpui::TestAppContext; use project::Project; @@ -621,30 +487,6 @@ mod tests { }); } - #[test] - fn should_parse_from_devcontainer_json() { - let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#; - let up: DevContainerUp = parse_json_from_cli(json).unwrap(); - assert_eq!(up._outcome, "success"); - assert_eq!( - up.container_id, - "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a" - ); - assert_eq!(up.remote_user, "vscode"); - assert_eq!(up.remote_workspace_folder, "/workspaces/zed"); - - let json_in_plaintext = r#"[2026-01-22T16:19:08.802Z] @devcontainers/cli 0.80.1. Node.js v22.21.1. darwin 24.6.0 arm64. - {"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#; - let up: DevContainerUp = parse_json_from_cli(json_in_plaintext).unwrap(); - assert_eq!(up._outcome, "success"); - assert_eq!( - up.container_id, - "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a" - ); - assert_eq!(up.remote_user, "vscode"); - assert_eq!(up.remote_workspace_folder, "/workspaces/zed"); - } - #[gpui::test] async fn test_find_configs_root_devcontainer_json(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/dev_container/src/devcontainer_json.rs b/crates/dev_container/src/devcontainer_json.rs new file mode 100644 index 0000000000000000000000000000000000000000..4429c63a37a87d1b54455b8169359ddf40511e24 --- /dev/null +++ b/crates/dev_container/src/devcontainer_json.rs @@ -0,0 +1,1358 @@ +use std::{collections::HashMap, fmt::Display, path::Path, sync::Arc}; + +use crate::{command_json::CommandRunner, devcontainer_api::DevContainerError}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json_lenient::Value; +use util::command::Command; + +#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)] +#[serde(untagged)] +pub(crate) enum ForwardPort { + Number(u16), + String(String), +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum PortAttributeProtocol { + Https, + Http, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum OnAutoForward { + Notify, + OpenBrowser, + OpenBrowserOnce, + OpenPreview, + Silent, + Ignore, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PortAttributes { + label: String, + on_auto_forward: OnAutoForward, + elevate_if_needed: bool, + require_local_port: bool, + protocol: PortAttributeProtocol, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum UserEnvProbe { + None, + InteractiveShell, + LoginShell, + LoginInteractiveShell, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum ShutdownAction { + None, + StopContainer, + StopCompose, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MountDefinition { + pub(crate) source: String, + pub(crate) target: String, + #[serde(rename = "type")] + pub(crate) mount_type: Option, +} + +impl Display for MountDefinition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "type={},source={},target={},consistency=cached", + self.mount_type.clone().unwrap_or_else(|| { + if self.source.starts_with('/') { + "bind".to_string() + } else { + "volume".to_string() + } + }), + self.source, + self.target + ) + } +} + +/// Represents the value associated with a feature ID in the `features` map of devcontainer.json. +/// +/// Per the spec, the value can be: +/// - A boolean (`true` to enable with defaults) +/// - A string (shorthand for `{"version": ""}`) +/// - An object mapping option names to string or boolean values +/// +/// See: https://containers.dev/implementors/features/#devcontainerjson-properties +#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)] +#[serde(untagged)] +pub(crate) enum FeatureOptions { + Bool(bool), + String(String), + Options(HashMap), +} + +#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)] +#[serde(untagged)] +pub(crate) enum FeatureOptionValue { + Bool(bool), + String(String), +} +impl std::fmt::Display for FeatureOptionValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FeatureOptionValue::Bool(b) => write!(f, "{}", b), + FeatureOptionValue::String(s) => write!(f, "{}", s), + } + } +} + +#[derive(Clone, Debug, Serialize, Eq, PartialEq, Default)] +pub(crate) struct ZedCustomizationsWrapper { + pub(crate) zed: ZedCustomization, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Default)] +pub(crate) struct ZedCustomization { + #[serde(default)] + pub(crate) extensions: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ContainerBuild { + pub(crate) dockerfile: String, + context: Option, + pub(crate) args: Option>, + options: Option>, + target: Option, + #[serde(default, deserialize_with = "deserialize_string_or_array")] + cache_from: Option>, +} + +#[derive(Clone, Debug, Serialize, Eq, PartialEq)] +struct LifecycleScriptInternal { + command: Option, + args: Vec, +} + +impl LifecycleScriptInternal { + fn from_args(args: Vec) -> Self { + let command = args.get(0).map(|a| a.to_string()); + let remaining = args.iter().skip(1).map(|a| a.to_string()).collect(); + Self { + command, + args: remaining, + } + } +} + +#[derive(Clone, Debug, Serialize, Eq, PartialEq)] +pub struct LifecycleScript { + scripts: HashMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HostRequirements { + cpus: Option, + memory: Option, + storage: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum LifecycleCommand { + InitializeCommand, + OnCreateCommand, + UpdateContentCommand, + PostCreateCommand, + PostStartCommand, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum DevContainerBuildType { + Image, + Dockerfile, + DockerCompose, + None, +} +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DevContainer { + pub(crate) image: Option, + pub(crate) name: Option, + pub(crate) remote_user: Option, + pub(crate) forward_ports: Option>, + pub(crate) ports_attributes: Option>, + pub(crate) other_ports_attributes: Option, + pub(crate) container_env: Option>, + pub(crate) remote_env: Option>, + pub(crate) container_user: Option, + #[serde(rename = "updateRemoteUserUID")] + pub(crate) update_remote_user_uid: Option, + user_env_probe: Option, + override_command: Option, + shutdown_action: Option, + init: Option, + pub(crate) privileged: Option, + cap_add: Option>, + security_opt: Option>, + #[serde(default, deserialize_with = "deserialize_mount_definitions")] + pub(crate) mounts: Option>, + pub(crate) features: Option>, + pub(crate) override_feature_install_order: Option>, + pub(crate) customizations: Option, + pub(crate) build: Option, + #[serde(default, deserialize_with = "deserialize_string_or_int")] + pub(crate) app_port: Option, + #[serde(default, deserialize_with = "deserialize_mount_definition")] + pub(crate) workspace_mount: Option, + pub(crate) workspace_folder: Option, + run_args: Option>, + #[serde(default, deserialize_with = "deserialize_string_or_array")] + pub(crate) docker_compose_file: Option>, + pub(crate) service: Option, + run_services: Option>, + pub(crate) initialize_command: Option, + pub(crate) on_create_command: Option, + pub(crate) update_content_command: Option, + pub(crate) post_create_command: Option, + pub(crate) post_start_command: Option, + pub(crate) post_attach_command: Option, + wait_for: Option, + host_requirements: Option, +} + +pub(crate) fn deserialize_devcontainer_json(json: &str) -> Result { + match serde_json_lenient::from_str(json) { + Ok(devcontainer) => Ok(devcontainer), + Err(e) => { + log::error!("Unable to deserialize devcontainer from json: {e}"); + Err(DevContainerError::DevContainerParseFailed) + } + } +} + +impl DevContainer { + pub(crate) fn build_type(&self) -> DevContainerBuildType { + if self.image.is_some() { + return DevContainerBuildType::Image; + } else if self.docker_compose_file.is_some() { + return DevContainerBuildType::DockerCompose; + } else if self.build.is_some() { + return DevContainerBuildType::Dockerfile; + } + return DevContainerBuildType::None; + } + + pub(crate) fn has_features(&self) -> bool { + self.features + .as_ref() + .map(|features| !features.is_empty()) + .unwrap_or(false) + } +} + +// Custom deserializer that parses the entire customizations object as a +// serde_json_lenient::Value first, then extracts the "zed" portion. +// This avoids a bug in serde_json_lenient's `ignore_value` codepath which +// does not handle trailing commas in skipped values. +impl<'de> Deserialize<'de> for ZedCustomizationsWrapper { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + let zed = value + .get("zed") + .map(|zed_value| serde_json_lenient::from_value::(zed_value.clone())) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(); + Ok(ZedCustomizationsWrapper { zed }) + } +} + +impl LifecycleScript { + fn from_map(args: HashMap>) -> Self { + Self { + scripts: args + .into_iter() + .map(|(k, v)| (k, LifecycleScriptInternal::from_args(v))) + .collect(), + } + } + fn from_str(args: &str) -> Self { + let script: Vec = args.split(" ").map(|a| a.to_string()).collect(); + + Self::from_args(script) + } + fn from_args(args: Vec) -> Self { + Self::from_map(HashMap::from([("default".to_string(), args)])) + } + pub fn script_commands(&self) -> HashMap { + self.scripts + .iter() + .filter_map(|(k, v)| { + if let Some(inner_command) = &v.command { + let mut command = Command::new(inner_command); + command.args(&v.args); + Some((k.clone(), command)) + } else { + log::warn!( + "Lifecycle script command {k}, value {:?} has no program to run. Skipping", + v + ); + None + } + }) + .collect() + } + + pub async fn run( + &self, + command_runnder: &Arc, + working_directory: &Path, + ) -> Result<(), DevContainerError> { + for (command_name, mut command) in self.script_commands() { + log::debug!("Running script {command_name}"); + + command.current_dir(working_directory); + + let output = command_runnder + .run_command(&mut command) + .await + .map_err(|e| { + log::error!("Error running command {command_name}: {e}"); + DevContainerError::CommandFailed(command_name.clone()) + })?; + if !output.status.success() { + let std_err = String::from_utf8_lossy(&output.stderr); + log::error!( + "Command {command_name} produced a non-successful output. StdErr: {std_err}" + ); + } + let std_out = String::from_utf8_lossy(&output.stdout); + log::debug!("Command {command_name} output:\n {std_out}"); + } + Ok(()) + } +} + +impl<'de> Deserialize<'de> for LifecycleScript { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::{self, Visitor}; + use std::fmt; + + struct LifecycleScriptVisitor; + + impl<'de> Visitor<'de> for LifecycleScriptVisitor { + type Value = LifecycleScript; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string, an array of strings, or a map of arrays") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + Ok(LifecycleScript::from_str(value)) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: de::SeqAccess<'de>, + { + let mut array = Vec::new(); + while let Some(elem) = seq.next_element()? { + array.push(elem); + } + Ok(LifecycleScript::from_args(array)) + } + + fn visit_map(self, mut map: A) -> Result + where + A: de::MapAccess<'de>, + { + let mut result = HashMap::new(); + while let Some(key) = map.next_key::()? { + let value: Value = map.next_value()?; + let script_args = match value { + Value::String(s) => { + s.split(" ").map(|s| s.to_string()).collect::>() + } + Value::Array(arr) => { + let strings: Vec = arr + .into_iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + strings + } + _ => continue, + }; + result.insert(key, script_args); + } + Ok(LifecycleScript::from_map(result)) + } + } + + deserializer.deserialize_any(LifecycleScriptVisitor) + } +} + +fn deserialize_mount_definition<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + use serde::de::Error; + + #[derive(Deserialize)] + #[serde(untagged)] + enum MountItem { + Object(MountDefinition), + String(String), + } + + let item = MountItem::deserialize(deserializer)?; + + let mount = match item { + MountItem::Object(mount) => mount, + MountItem::String(s) => { + let mut source = None; + let mut target = None; + let mut mount_type = None; + + for part in s.split(',') { + let part = part.trim(); + if let Some((key, value)) = part.split_once('=') { + match key.trim() { + "source" => source = Some(value.trim().to_string()), + "target" => target = Some(value.trim().to_string()), + "type" => mount_type = Some(value.trim().to_string()), + _ => {} // Ignore unknown keys + } + } + } + + let source = source + .ok_or_else(|| D::Error::custom(format!("mount string missing 'source': {}", s)))?; + let target = target + .ok_or_else(|| D::Error::custom(format!("mount string missing 'target': {}", s)))?; + + MountDefinition { + source, + target, + mount_type, + } + } + }; + + Ok(Some(mount)) +} + +fn deserialize_mount_definitions<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + use serde::de::Error; + + #[derive(Deserialize)] + #[serde(untagged)] + enum MountItem { + Object(MountDefinition), + String(String), + } + + let items = Vec::::deserialize(deserializer)?; + let mut mounts = Vec::new(); + + for item in items { + match item { + MountItem::Object(mount) => mounts.push(mount), + MountItem::String(s) => { + let mut source = None; + let mut target = None; + let mut mount_type = None; + + for part in s.split(',') { + let part = part.trim(); + if let Some((key, value)) = part.split_once('=') { + match key.trim() { + "source" => source = Some(value.trim().to_string()), + "target" => target = Some(value.trim().to_string()), + "type" => mount_type = Some(value.trim().to_string()), + _ => {} // Ignore unknown keys + } + } + } + + let source = source.ok_or_else(|| { + D::Error::custom(format!("mount string missing 'source': {}", s)) + })?; + let target = target.ok_or_else(|| { + D::Error::custom(format!("mount string missing 'target': {}", s)) + })?; + + mounts.push(MountDefinition { + source, + target, + mount_type, + }); + } + } + } + + Ok(Some(mounts)) +} + +fn deserialize_string_or_int<'de, D>(deserializer: D) -> Result, 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(Some(s)), + StringOrInt::Int(b) => Ok(Some(b.to_string())), + } +} + +fn deserialize_string_or_array<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrArray { + String(String), + Array(Vec), + } + + match StringOrArray::deserialize(deserializer)? { + StringOrArray::String(s) => Ok(Some(vec![s])), + StringOrArray::Array(b) => Ok(Some(b)), + } +} + +#[cfg(test)] +mod test { + use std::collections::HashMap; + + use crate::{ + devcontainer_api::DevContainerError, + devcontainer_json::{ + ContainerBuild, DevContainer, DevContainerBuildType, FeatureOptions, ForwardPort, + HostRequirements, LifecycleCommand, LifecycleScript, MountDefinition, OnAutoForward, + PortAttributeProtocol, PortAttributes, ShutdownAction, UserEnvProbe, ZedCustomization, + ZedCustomizationsWrapper, deserialize_devcontainer_json, + }, + }; + + #[test] + fn should_deserialize_customizations_with_unknown_keys() { + let json_with_other_customizations = r#" + { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "GitHub.vscode-pull-request-github", + ], + }, + "zed": { + "extensions": ["vue", "ruby"], + }, + "codespaces": { + "repositories": { + "devcontainers/features": { + "permissions": { + "contents": "write", + "workflows": "write", + }, + }, + }, + }, + }, + } + "#; + + let result = deserialize_devcontainer_json(json_with_other_customizations); + + assert!( + result.is_ok(), + "Should ignore unknown customization keys, but got: {:?}", + result.err() + ); + let devcontainer = result.expect("ok"); + assert_eq!( + devcontainer.customizations, + Some(ZedCustomizationsWrapper { + zed: ZedCustomization { + extensions: vec!["vue".to_string(), "ruby".to_string()] + } + }) + ); + } + + #[test] + fn should_deserialize_customizations_without_zed_key() { + let json_without_zed = r#" + { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "customizations": { + "vscode": { + "extensions": ["dbaeumer.vscode-eslint"] + } + } + } + "#; + + let result = deserialize_devcontainer_json(json_without_zed); + + assert!( + result.is_ok(), + "Should handle missing zed key in customizations, but got: {:?}", + result.err() + ); + let devcontainer = result.expect("ok"); + assert_eq!( + devcontainer.customizations, + Some(ZedCustomizationsWrapper { + zed: ZedCustomization { extensions: vec![] } + }) + ); + } + + #[test] + fn should_deserialize_simple_devcontainer_json() { + let given_bad_json = "{ \"image\": 123 }"; + + let result = deserialize_devcontainer_json(given_bad_json); + + assert!(result.is_err()); + assert_eq!( + result.expect_err("err"), + DevContainerError::DevContainerParseFailed + ); + + let given_image_container_json = r#" + // These are some external comments. serde_lenient should handle them + { + // These are some internal comments + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "name": "myDevContainer", + "remoteUser": "root", + "forwardPorts": [ + "db:5432", + 3000 + ], + "portsAttributes": { + "3000": { + "label": "This Port", + "onAutoForward": "notify", + "elevateIfNeeded": false, + "requireLocalPort": true, + "protocol": "https" + }, + "db:5432": { + "label": "This Port too", + "onAutoForward": "silent", + "elevateIfNeeded": true, + "requireLocalPort": false, + "protocol": "http" + } + }, + "otherPortsAttributes": { + "label": "Other Ports", + "onAutoForward": "openBrowser", + "elevateIfNeeded": true, + "requireLocalPort": true, + "protocol": "https" + }, + "updateRemoteUserUID": true, + "remoteEnv": { + "MYVAR1": "myvarvalue", + "MYVAR2": "myvarothervalue" + }, + "initializeCommand": ["echo", "initialize_command"], + "onCreateCommand": "echo on_create_command", + "updateContentCommand": { + "first": "echo update_content_command", + "second": ["echo", "update_content_command"] + }, + "postCreateCommand": ["echo", "post_create_command"], + "postStartCommand": "echo post_start_command", + "postAttachCommand": { + "something": "echo post_attach_command", + "something1": "echo something else", + }, + "waitFor": "postStartCommand", + "userEnvProbe": "loginShell", + "features": { + "ghcr.io/devcontainers/features/aws-cli:1": {}, + "ghcr.io/devcontainers/features/anaconda:1": {} + }, + "overrideFeatureInstallOrder": [ + "ghcr.io/devcontainers/features/anaconda:1", + "ghcr.io/devcontainers/features/aws-cli:1" + ], + "hostRequirements": { + "cpus": 2, + "memory": "8gb", + "storage": "32gb", + // Note that we're not parsing this currently + "gpu": true, + }, + "appPort": 8081, + "containerEnv": { + "MYVAR3": "myvar3", + "MYVAR4": "myvar4" + }, + "containerUser": "myUser", + "mounts": [ + { + "source": "/localfolder/app", + "target": "/workspaces/app", + "type": "volume" + } + ], + "runArgs": [ + "-c", + "some_command" + ], + "shutdownAction": "stopContainer", + "overrideCommand": true, + "workspaceFolder": "/workspaces", + "workspaceMount": "source=/app,target=/workspaces/app,type=bind,consistency=cached", + "customizations": { + "vscode": { + // Just confirm that this can be included and ignored + }, + "zed": { + "extensions": [ + "html" + ] + } + } + } + "#; + + let result = deserialize_devcontainer_json(given_image_container_json); + + assert!(result.is_ok()); + let devcontainer = result.expect("ok"); + assert_eq!( + devcontainer, + DevContainer { + image: Some(String::from("mcr.microsoft.com/devcontainers/base:ubuntu")), + name: Some(String::from("myDevContainer")), + remote_user: Some(String::from("root")), + forward_ports: Some(vec![ + ForwardPort::String("db:5432".to_string()), + ForwardPort::Number(3000), + ]), + ports_attributes: Some(HashMap::from([ + ( + "3000".to_string(), + PortAttributes { + label: "This Port".to_string(), + on_auto_forward: OnAutoForward::Notify, + elevate_if_needed: false, + require_local_port: true, + protocol: PortAttributeProtocol::Https + } + ), + ( + "db:5432".to_string(), + PortAttributes { + label: "This Port too".to_string(), + on_auto_forward: OnAutoForward::Silent, + elevate_if_needed: true, + require_local_port: false, + protocol: PortAttributeProtocol::Http + } + ) + ])), + other_ports_attributes: Some(PortAttributes { + label: "Other Ports".to_string(), + on_auto_forward: OnAutoForward::OpenBrowser, + elevate_if_needed: true, + require_local_port: true, + protocol: PortAttributeProtocol::Https + }), + update_remote_user_uid: Some(true), + remote_env: Some(HashMap::from([ + ("MYVAR1".to_string(), "myvarvalue".to_string()), + ("MYVAR2".to_string(), "myvarothervalue".to_string()) + ])), + initialize_command: Some(LifecycleScript::from_args(vec![ + "echo".to_string(), + "initialize_command".to_string() + ])), + on_create_command: Some(LifecycleScript::from_str("echo on_create_command")), + update_content_command: Some(LifecycleScript::from_map(HashMap::from([ + ( + "first".to_string(), + vec!["echo".to_string(), "update_content_command".to_string()] + ), + ( + "second".to_string(), + vec!["echo".to_string(), "update_content_command".to_string()] + ) + ]))), + post_create_command: Some(LifecycleScript::from_str("echo post_create_command")), + post_start_command: Some(LifecycleScript::from_args(vec![ + "echo".to_string(), + "post_start_command".to_string() + ])), + post_attach_command: Some(LifecycleScript::from_map(HashMap::from([ + ( + "something".to_string(), + vec!["echo".to_string(), "post_attach_command".to_string()] + ), + ( + "something1".to_string(), + vec![ + "echo".to_string(), + "something".to_string(), + "else".to_string() + ] + ) + ]))), + wait_for: Some(LifecycleCommand::PostStartCommand), + user_env_probe: Some(UserEnvProbe::LoginShell), + features: Some(HashMap::from([ + ( + "ghcr.io/devcontainers/features/aws-cli:1".to_string(), + FeatureOptions::Options(HashMap::new()) + ), + ( + "ghcr.io/devcontainers/features/anaconda:1".to_string(), + FeatureOptions::Options(HashMap::new()) + ) + ])), + override_feature_install_order: Some(vec![ + "ghcr.io/devcontainers/features/anaconda:1".to_string(), + "ghcr.io/devcontainers/features/aws-cli:1".to_string() + ]), + host_requirements: Some(HostRequirements { + cpus: Some(2), + memory: Some("8gb".to_string()), + storage: Some("32gb".to_string()), + }), + app_port: Some("8081".to_string()), + container_env: Some(HashMap::from([ + ("MYVAR3".to_string(), "myvar3".to_string()), + ("MYVAR4".to_string(), "myvar4".to_string()) + ])), + container_user: Some("myUser".to_string()), + mounts: Some(vec![MountDefinition { + source: "/localfolder/app".to_string(), + target: "/workspaces/app".to_string(), + mount_type: Some("volume".to_string()), + }]), + run_args: Some(vec!["-c".to_string(), "some_command".to_string()]), + shutdown_action: Some(ShutdownAction::StopContainer), + override_command: Some(true), + workspace_folder: Some("/workspaces".to_string()), + workspace_mount: Some(MountDefinition { + source: "/app".to_string(), + target: "/workspaces/app".to_string(), + mount_type: Some("bind".to_string()) + }), + customizations: Some(ZedCustomizationsWrapper { + zed: ZedCustomization { + extensions: vec!["html".to_string()] + } + }), + ..Default::default() + } + ); + + assert_eq!(devcontainer.build_type(), DevContainerBuildType::Image); + } + + #[test] + fn should_deserialize_docker_compose_devcontainer_json() { + let given_docker_compose_json = r#" + // These are some external comments. serde_lenient should handle them + { + // These are some internal comments + "name": "myDevContainer", + "remoteUser": "root", + "forwardPorts": [ + "db:5432", + 3000 + ], + "portsAttributes": { + "3000": { + "label": "This Port", + "onAutoForward": "notify", + "elevateIfNeeded": false, + "requireLocalPort": true, + "protocol": "https" + }, + "db:5432": { + "label": "This Port too", + "onAutoForward": "silent", + "elevateIfNeeded": true, + "requireLocalPort": false, + "protocol": "http" + } + }, + "otherPortsAttributes": { + "label": "Other Ports", + "onAutoForward": "openBrowser", + "elevateIfNeeded": true, + "requireLocalPort": true, + "protocol": "https" + }, + "updateRemoteUserUID": true, + "remoteEnv": { + "MYVAR1": "myvarvalue", + "MYVAR2": "myvarothervalue" + }, + "initializeCommand": ["echo", "initialize_command"], + "onCreateCommand": "echo on_create_command", + "updateContentCommand": { + "first": "echo update_content_command", + "second": ["echo", "update_content_command"] + }, + "postCreateCommand": ["echo", "post_create_command"], + "postStartCommand": "echo post_start_command", + "postAttachCommand": { + "something": "echo post_attach_command", + "something1": "echo something else", + }, + "waitFor": "postStartCommand", + "userEnvProbe": "loginShell", + "features": { + "ghcr.io/devcontainers/features/aws-cli:1": {}, + "ghcr.io/devcontainers/features/anaconda:1": {} + }, + "overrideFeatureInstallOrder": [ + "ghcr.io/devcontainers/features/anaconda:1", + "ghcr.io/devcontainers/features/aws-cli:1" + ], + "hostRequirements": { + "cpus": 2, + "memory": "8gb", + "storage": "32gb", + // Note that we're not parsing this currently + "gpu": true, + }, + "dockerComposeFile": "docker-compose.yml", + "service": "myService", + "runServices": [ + "myService", + "mySupportingService" + ], + "workspaceFolder": "/workspaces/thing", + "shutdownAction": "stopCompose", + "overrideCommand": true + } + "#; + let result = deserialize_devcontainer_json(given_docker_compose_json); + + assert!(result.is_ok()); + let devcontainer = result.expect("ok"); + assert_eq!( + devcontainer, + DevContainer { + name: Some(String::from("myDevContainer")), + remote_user: Some(String::from("root")), + forward_ports: Some(vec![ + ForwardPort::String("db:5432".to_string()), + ForwardPort::Number(3000), + ]), + ports_attributes: Some(HashMap::from([ + ( + "3000".to_string(), + PortAttributes { + label: "This Port".to_string(), + on_auto_forward: OnAutoForward::Notify, + elevate_if_needed: false, + require_local_port: true, + protocol: PortAttributeProtocol::Https + } + ), + ( + "db:5432".to_string(), + PortAttributes { + label: "This Port too".to_string(), + on_auto_forward: OnAutoForward::Silent, + elevate_if_needed: true, + require_local_port: false, + protocol: PortAttributeProtocol::Http + } + ) + ])), + other_ports_attributes: Some(PortAttributes { + label: "Other Ports".to_string(), + on_auto_forward: OnAutoForward::OpenBrowser, + elevate_if_needed: true, + require_local_port: true, + protocol: PortAttributeProtocol::Https + }), + update_remote_user_uid: Some(true), + remote_env: Some(HashMap::from([ + ("MYVAR1".to_string(), "myvarvalue".to_string()), + ("MYVAR2".to_string(), "myvarothervalue".to_string()) + ])), + initialize_command: Some(LifecycleScript::from_args(vec![ + "echo".to_string(), + "initialize_command".to_string() + ])), + on_create_command: Some(LifecycleScript::from_str("echo on_create_command")), + update_content_command: Some(LifecycleScript::from_map(HashMap::from([ + ( + "first".to_string(), + vec!["echo".to_string(), "update_content_command".to_string()] + ), + ( + "second".to_string(), + vec!["echo".to_string(), "update_content_command".to_string()] + ) + ]))), + post_create_command: Some(LifecycleScript::from_str("echo post_create_command")), + post_start_command: Some(LifecycleScript::from_args(vec![ + "echo".to_string(), + "post_start_command".to_string() + ])), + post_attach_command: Some(LifecycleScript::from_map(HashMap::from([ + ( + "something".to_string(), + vec!["echo".to_string(), "post_attach_command".to_string()] + ), + ( + "something1".to_string(), + vec![ + "echo".to_string(), + "something".to_string(), + "else".to_string() + ] + ) + ]))), + wait_for: Some(LifecycleCommand::PostStartCommand), + user_env_probe: Some(UserEnvProbe::LoginShell), + features: Some(HashMap::from([ + ( + "ghcr.io/devcontainers/features/aws-cli:1".to_string(), + FeatureOptions::Options(HashMap::new()) + ), + ( + "ghcr.io/devcontainers/features/anaconda:1".to_string(), + FeatureOptions::Options(HashMap::new()) + ) + ])), + override_feature_install_order: Some(vec![ + "ghcr.io/devcontainers/features/anaconda:1".to_string(), + "ghcr.io/devcontainers/features/aws-cli:1".to_string() + ]), + host_requirements: Some(HostRequirements { + cpus: Some(2), + memory: Some("8gb".to_string()), + storage: Some("32gb".to_string()), + }), + docker_compose_file: Some(vec!["docker-compose.yml".to_string()]), + service: Some("myService".to_string()), + run_services: Some(vec![ + "myService".to_string(), + "mySupportingService".to_string(), + ]), + workspace_folder: Some("/workspaces/thing".to_string()), + shutdown_action: Some(ShutdownAction::StopCompose), + override_command: Some(true), + ..Default::default() + } + ); + + assert_eq!( + devcontainer.build_type(), + DevContainerBuildType::DockerCompose + ); + } + + #[test] + fn should_deserialize_dockerfile_devcontainer_json() { + let given_dockerfile_container_json = r#" + // These are some external comments. serde_lenient should handle them + { + // These are some internal comments + "name": "myDevContainer", + "remoteUser": "root", + "forwardPorts": [ + "db:5432", + 3000 + ], + "portsAttributes": { + "3000": { + "label": "This Port", + "onAutoForward": "notify", + "elevateIfNeeded": false, + "requireLocalPort": true, + "protocol": "https" + }, + "db:5432": { + "label": "This Port too", + "onAutoForward": "silent", + "elevateIfNeeded": true, + "requireLocalPort": false, + "protocol": "http" + } + }, + "otherPortsAttributes": { + "label": "Other Ports", + "onAutoForward": "openBrowser", + "elevateIfNeeded": true, + "requireLocalPort": true, + "protocol": "https" + }, + "updateRemoteUserUID": true, + "remoteEnv": { + "MYVAR1": "myvarvalue", + "MYVAR2": "myvarothervalue" + }, + "initializeCommand": ["echo", "initialize_command"], + "onCreateCommand": "echo on_create_command", + "updateContentCommand": { + "first": "echo update_content_command", + "second": ["echo", "update_content_command"] + }, + "postCreateCommand": ["echo", "post_create_command"], + "postStartCommand": "echo post_start_command", + "postAttachCommand": { + "something": "echo post_attach_command", + "something1": "echo something else", + }, + "waitFor": "postStartCommand", + "userEnvProbe": "loginShell", + "features": { + "ghcr.io/devcontainers/features/aws-cli:1": {}, + "ghcr.io/devcontainers/features/anaconda:1": {} + }, + "overrideFeatureInstallOrder": [ + "ghcr.io/devcontainers/features/anaconda:1", + "ghcr.io/devcontainers/features/aws-cli:1" + ], + "hostRequirements": { + "cpus": 2, + "memory": "8gb", + "storage": "32gb", + // Note that we're not parsing this currently + "gpu": true, + }, + "appPort": 8081, + "containerEnv": { + "MYVAR3": "myvar3", + "MYVAR4": "myvar4" + }, + "containerUser": "myUser", + "mounts": [ + { + "source": "/localfolder/app", + "target": "/workspaces/app", + "type": "volume" + }, + "source=dev-containers-cli-bashhistory,target=/home/node/commandhistory", + ], + "runArgs": [ + "-c", + "some_command" + ], + "shutdownAction": "stopContainer", + "overrideCommand": true, + "workspaceFolder": "/workspaces", + "workspaceMount": "source=/folder,target=/workspace,type=bind,consistency=cached", + "build": { + "dockerfile": "DockerFile", + "context": "..", + "args": { + "MYARG": "MYVALUE" + }, + "options": [ + "--some-option", + "--mount" + ], + "target": "development", + "cacheFrom": "some_image" + } + } + "#; + + let result = deserialize_devcontainer_json(given_dockerfile_container_json); + + assert!(result.is_ok()); + let devcontainer = result.expect("ok"); + assert_eq!( + devcontainer, + DevContainer { + name: Some(String::from("myDevContainer")), + remote_user: Some(String::from("root")), + forward_ports: Some(vec![ + ForwardPort::String("db:5432".to_string()), + ForwardPort::Number(3000), + ]), + ports_attributes: Some(HashMap::from([ + ( + "3000".to_string(), + PortAttributes { + label: "This Port".to_string(), + on_auto_forward: OnAutoForward::Notify, + elevate_if_needed: false, + require_local_port: true, + protocol: PortAttributeProtocol::Https + } + ), + ( + "db:5432".to_string(), + PortAttributes { + label: "This Port too".to_string(), + on_auto_forward: OnAutoForward::Silent, + elevate_if_needed: true, + require_local_port: false, + protocol: PortAttributeProtocol::Http + } + ) + ])), + other_ports_attributes: Some(PortAttributes { + label: "Other Ports".to_string(), + on_auto_forward: OnAutoForward::OpenBrowser, + elevate_if_needed: true, + require_local_port: true, + protocol: PortAttributeProtocol::Https + }), + update_remote_user_uid: Some(true), + remote_env: Some(HashMap::from([ + ("MYVAR1".to_string(), "myvarvalue".to_string()), + ("MYVAR2".to_string(), "myvarothervalue".to_string()) + ])), + initialize_command: Some(LifecycleScript::from_args(vec![ + "echo".to_string(), + "initialize_command".to_string() + ])), + on_create_command: Some(LifecycleScript::from_str("echo on_create_command")), + update_content_command: Some(LifecycleScript::from_map(HashMap::from([ + ( + "first".to_string(), + vec!["echo".to_string(), "update_content_command".to_string()] + ), + ( + "second".to_string(), + vec!["echo".to_string(), "update_content_command".to_string()] + ) + ]))), + post_create_command: Some(LifecycleScript::from_str("echo post_create_command")), + post_start_command: Some(LifecycleScript::from_args(vec![ + "echo".to_string(), + "post_start_command".to_string() + ])), + post_attach_command: Some(LifecycleScript::from_map(HashMap::from([ + ( + "something".to_string(), + vec!["echo".to_string(), "post_attach_command".to_string()] + ), + ( + "something1".to_string(), + vec![ + "echo".to_string(), + "something".to_string(), + "else".to_string() + ] + ) + ]))), + wait_for: Some(LifecycleCommand::PostStartCommand), + user_env_probe: Some(UserEnvProbe::LoginShell), + features: Some(HashMap::from([ + ( + "ghcr.io/devcontainers/features/aws-cli:1".to_string(), + FeatureOptions::Options(HashMap::new()) + ), + ( + "ghcr.io/devcontainers/features/anaconda:1".to_string(), + FeatureOptions::Options(HashMap::new()) + ) + ])), + override_feature_install_order: Some(vec![ + "ghcr.io/devcontainers/features/anaconda:1".to_string(), + "ghcr.io/devcontainers/features/aws-cli:1".to_string() + ]), + host_requirements: Some(HostRequirements { + cpus: Some(2), + memory: Some("8gb".to_string()), + storage: Some("32gb".to_string()), + }), + app_port: Some("8081".to_string()), + container_env: Some(HashMap::from([ + ("MYVAR3".to_string(), "myvar3".to_string()), + ("MYVAR4".to_string(), "myvar4".to_string()) + ])), + container_user: Some("myUser".to_string()), + mounts: Some(vec![ + MountDefinition { + source: "/localfolder/app".to_string(), + target: "/workspaces/app".to_string(), + mount_type: Some("volume".to_string()), + }, + MountDefinition { + source: "dev-containers-cli-bashhistory".to_string(), + target: "/home/node/commandhistory".to_string(), + mount_type: None, + } + ]), + run_args: Some(vec!["-c".to_string(), "some_command".to_string()]), + shutdown_action: Some(ShutdownAction::StopContainer), + override_command: Some(true), + workspace_folder: Some("/workspaces".to_string()), + workspace_mount: Some(MountDefinition { + source: "/folder".to_string(), + target: "/workspace".to_string(), + mount_type: Some("bind".to_string()) + }), + build: Some(ContainerBuild { + dockerfile: "DockerFile".to_string(), + context: Some("..".to_string()), + args: Some(HashMap::from([( + "MYARG".to_string(), + "MYVALUE".to_string() + )])), + options: Some(vec!["--some-option".to_string(), "--mount".to_string()]), + target: Some("development".to_string()), + cache_from: Some(vec!["some_image".to_string()]), + }), + ..Default::default() + } + ); + + assert_eq!(devcontainer.build_type(), DevContainerBuildType::Dockerfile); + } +} diff --git a/crates/dev_container/src/devcontainer_manifest.rs b/crates/dev_container/src/devcontainer_manifest.rs new file mode 100644 index 0000000000000000000000000000000000000000..1c2863f96118b5bac006f3a590da8cf8980994e2 --- /dev/null +++ b/crates/dev_container/src/devcontainer_manifest.rs @@ -0,0 +1,6571 @@ +use std::{ + collections::HashMap, + fmt::Debug, + hash::{DefaultHasher, Hash, Hasher}, + path::{Path, PathBuf}, + sync::Arc, +}; + +use fs::Fs; +use http_client::HttpClient; +use util::{ResultExt, command::Command}; + +use crate::{ + DevContainerConfig, DevContainerContext, + command_json::{CommandRunner, DefaultCommandRunner}, + devcontainer_api::{DevContainerError, DevContainerUp}, + devcontainer_json::{ + DevContainer, DevContainerBuildType, FeatureOptions, ForwardPort, MountDefinition, + deserialize_devcontainer_json, + }, + docker::{ + Docker, DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild, + DockerComposeVolume, DockerInspect, DockerPs, get_remote_dir_from_config, + }, + features::{DevContainerFeatureJson, FeatureManifest, parse_oci_feature_ref}, + get_oci_token, + oci::{TokenResponse, download_oci_tarball, get_oci_manifest}, + safe_id_lower, +}; + +enum ConfigStatus { + Deserialized(DevContainer), + VariableParsed(DevContainer), +} + +#[derive(Debug, Clone, Eq, PartialEq, Default)] +pub(crate) struct DockerComposeResources { + files: Vec, + config: DockerComposeConfig, +} + +struct DevContainerManifest { + http_client: Arc, + fs: Arc, + docker_client: Arc, + command_runner: Arc, + raw_config: String, + config: ConfigStatus, + local_environment: HashMap, + local_project_directory: PathBuf, + config_directory: PathBuf, + file_name: String, + root_image: Option, + features_build_info: Option, + features: Vec, +} +const DEFAULT_REMOTE_PROJECT_DIR: &str = "/workspaces/"; +impl DevContainerManifest { + async fn new( + context: &DevContainerContext, + environment: HashMap, + docker_client: Arc, + command_runner: Arc, + local_config: DevContainerConfig, + local_project_path: &Path, + ) -> Result { + let config_path = local_project_path.join(local_config.config_path.clone()); + log::debug!("parsing devcontainer json found in {:?}", &config_path); + let devcontainer_contents = context.fs.load(&config_path).await.map_err(|e| { + log::error!("Unable to read devcontainer contents: {e}"); + DevContainerError::DevContainerParseFailed + })?; + + let devcontainer = deserialize_devcontainer_json(&devcontainer_contents)?; + + let devcontainer_directory = config_path.parent().ok_or_else(|| { + log::error!("Dev container file should be in a directory"); + DevContainerError::NotInValidProject + })?; + let file_name = config_path + .file_name() + .and_then(|f| f.to_str()) + .ok_or_else(|| { + log::error!("Dev container file has no file name, or is invalid unicode"); + DevContainerError::DevContainerParseFailed + })?; + + Ok(Self { + fs: context.fs.clone(), + http_client: context.http_client.clone(), + docker_client, + command_runner, + raw_config: devcontainer_contents, + config: ConfigStatus::Deserialized(devcontainer), + local_project_directory: local_project_path.to_path_buf(), + local_environment: environment, + config_directory: devcontainer_directory.to_path_buf(), + file_name: file_name.to_string(), + root_image: None, + features_build_info: None, + features: Vec::new(), + }) + } + + fn devcontainer_id(&self) -> String { + let mut labels = self.identifying_labels(); + labels.sort_by_key(|(key, _)| *key); + + let mut hasher = DefaultHasher::new(); + for (key, value) in &labels { + key.hash(&mut hasher); + value.hash(&mut hasher); + } + + format!("{:016x}", hasher.finish()) + } + + fn identifying_labels(&self) -> Vec<(&str, String)> { + let labels = vec![ + ( + "devcontainer.local_folder", + (self.local_project_directory.display()).to_string(), + ), + ( + "devcontainer.config_file", + (self.config_file().display()).to_string(), + ), + ]; + labels + } + + fn parse_nonremote_vars_for_content(&self, content: &str) -> Result { + let mut replaced_content = content + .replace("${devcontainerId}", &self.devcontainer_id()) + .replace( + "${containerWorkspaceFolderBasename}", + &self.remote_workspace_base_name().unwrap_or_default(), + ) + .replace( + "${localWorkspaceFolderBasename}", + &self.local_workspace_base_name()?, + ) + .replace( + "${containerWorkspaceFolder}", + &self + .remote_workspace_folder() + .map(|path| path.display().to_string()) + .unwrap_or_default() + .replace('\\', "/"), + ) + .replace( + "${localWorkspaceFolder}", + &self.local_workspace_folder().replace('\\', "/"), + ); + for (k, v) in &self.local_environment { + let find = format!("${{localEnv:{k}}}"); + replaced_content = replaced_content.replace(&find, &v.replace('\\', "/")); + } + + Ok(replaced_content) + } + + fn parse_nonremote_vars(&mut self) -> Result<(), DevContainerError> { + let replaced_content = self.parse_nonremote_vars_for_content(&self.raw_config)?; + let parsed_config = deserialize_devcontainer_json(&replaced_content)?; + + self.config = ConfigStatus::VariableParsed(parsed_config); + + Ok(()) + } + + fn runtime_remote_env( + &self, + container_env: &HashMap, + ) -> Result, DevContainerError> { + let mut merged_remote_env = container_env.clone(); + // HOME is user-specific, and we will often not run as the image user + merged_remote_env.remove("HOME"); + if let Some(remote_env) = self.dev_container().remote_env.clone() { + let mut raw = serde_json_lenient::to_string(&remote_env).map_err(|e| { + log::error!( + "Unexpected error serializing dev container remote_env: {e} - {:?}", + remote_env + ); + DevContainerError::DevContainerParseFailed + })?; + for (k, v) in container_env { + raw = raw.replace(&format!("${{containerEnv:{k}}}"), v); + } + let reserialized: HashMap = serde_json_lenient::from_str(&raw) + .map_err(|e| { + log::error!( + "Unexpected error reserializing dev container remote env: {e} - {:?}", + &raw + ); + DevContainerError::DevContainerParseFailed + })?; + for (k, v) in reserialized { + merged_remote_env.insert(k, v); + } + } + Ok(merged_remote_env) + } + + fn config_file(&self) -> PathBuf { + self.config_directory.join(&self.file_name) + } + + fn dev_container(&self) -> &DevContainer { + match &self.config { + ConfigStatus::Deserialized(dev_container) => dev_container, + ConfigStatus::VariableParsed(dev_container) => dev_container, + } + } + + async fn dockerfile_location(&self) -> Option { + let dev_container = self.dev_container(); + match dev_container.build_type() { + DevContainerBuildType::Image => None, + DevContainerBuildType::Dockerfile => dev_container + .build + .as_ref() + .map(|build| self.config_directory.join(&build.dockerfile)), + DevContainerBuildType::DockerCompose => { + let Ok(docker_compose_manifest) = self.docker_compose_manifest().await else { + return None; + }; + let Ok((_, main_service)) = find_primary_service(&docker_compose_manifest, self) + else { + return None; + }; + main_service + .build + .and_then(|b| b.dockerfile) + .map(|dockerfile| self.config_directory.join(dockerfile)) + } + DevContainerBuildType::None => None, + } + } + + fn generate_features_image_tag(&self, dockerfile_build_path: String) -> String { + let mut hasher = DefaultHasher::new(); + let prefix = match &self.dev_container().name { + Some(name) => &safe_id_lower(name), + None => "zed-dc", + }; + let prefix = prefix.get(..6).unwrap_or(prefix); + + dockerfile_build_path.hash(&mut hasher); + + let hash = hasher.finish(); + format!("{}-{:x}-features", prefix, hash) + } + + /// Gets the base image from the devcontainer with the following precedence: + /// - The devcontainer image if an image is specified + /// - The image sourced in the Dockerfile if a Dockerfile is specified + /// - The image sourced in the docker-compose main service, if one is specified + /// - The image sourced in the docker-compose main service dockerfile, if one is specified + /// If no such image is available, return an error + async fn get_base_image_from_config(&self) -> Result { + if let Some(image) = &self.dev_container().image { + return Ok(image.to_string()); + } + if let Some(dockerfile) = self.dev_container().build.as_ref().map(|b| &b.dockerfile) { + let dockerfile_contents = self + .fs + .load(&self.config_directory.join(dockerfile)) + .await + .map_err(|e| { + log::error!("Error reading dockerfile: {e}"); + DevContainerError::DevContainerParseFailed + })?; + return image_from_dockerfile(self, dockerfile_contents); + } + if self.dev_container().docker_compose_file.is_some() { + let docker_compose_manifest = self.docker_compose_manifest().await?; + let (_, main_service) = find_primary_service(&docker_compose_manifest, &self)?; + + if let Some(dockerfile) = main_service + .build + .as_ref() + .and_then(|b| b.dockerfile.as_ref()) + { + let dockerfile_contents = self + .fs + .load(&self.config_directory.join(dockerfile)) + .await + .map_err(|e| { + log::error!("Error reading dockerfile: {e}"); + DevContainerError::DevContainerParseFailed + })?; + return image_from_dockerfile(self, dockerfile_contents); + } + if let Some(image) = &main_service.image { + return Ok(image.to_string()); + } + + log::error!("No valid base image found in docker-compose configuration"); + return Err(DevContainerError::DevContainerParseFailed); + } + log::error!("No valid base image found in dev container configuration"); + Err(DevContainerError::DevContainerParseFailed) + } + + async fn download_feature_and_dockerfile_resources(&mut self) -> Result<(), DevContainerError> { + let dev_container = match &self.config { + ConfigStatus::Deserialized(_) => { + log::error!( + "Dev container has not yet been parsed for variable expansion. Cannot yet download resources" + ); + return Err(DevContainerError::DevContainerParseFailed); + } + ConfigStatus::VariableParsed(dev_container) => dev_container, + }; + let root_image_tag = self.get_base_image_from_config().await?; + let root_image = self.docker_client.inspect(&root_image_tag).await?; + + if dev_container.build_type() == DevContainerBuildType::Image + && !dev_container.has_features() + { + log::debug!("No resources to download. Proceeding with just the image"); + return Ok(()); + } + + let temp_base = std::env::temp_dir().join("devcontainer-zed"); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + + let features_content_dir = temp_base.join(format!("container-features-{}", timestamp)); + let empty_context_dir = temp_base.join("empty-folder"); + + self.fs + .create_dir(&features_content_dir) + .await + .map_err(|e| { + log::error!("Failed to create features content dir: {e}"); + DevContainerError::FilesystemError + })?; + + self.fs.create_dir(&empty_context_dir).await.map_err(|e| { + log::error!("Failed to create empty context dir: {e}"); + DevContainerError::FilesystemError + })?; + + let dockerfile_path = features_content_dir.join("Dockerfile.extended"); + let image_tag = + self.generate_features_image_tag(dockerfile_path.clone().display().to_string()); + + let build_info = FeaturesBuildInfo { + dockerfile_path, + features_content_dir, + empty_context_dir, + build_image: dev_container.image.clone(), + image_tag, + }; + + let features = match &dev_container.features { + Some(features) => features, + None => &HashMap::new(), + }; + + let container_user = get_container_user_from_config(&root_image, self)?; + let remote_user = get_remote_user_from_config(&root_image, self)?; + + let builtin_env_content = format!( + "_CONTAINER_USER={}\n_REMOTE_USER={}\n", + container_user, remote_user + ); + + let builtin_env_path = build_info + .features_content_dir + .join("devcontainer-features.builtin.env"); + + self.fs + .write(&builtin_env_path, &builtin_env_content.as_bytes()) + .await + .map_err(|e| { + log::error!("Failed to write builtin env file: {e}"); + DevContainerError::FilesystemError + })?; + + let ordered_features = + resolve_feature_order(features, &dev_container.override_feature_install_order); + + for (index, (feature_ref, options)) in ordered_features.iter().enumerate() { + if matches!(options, FeatureOptions::Bool(false)) { + log::debug!( + "Feature '{}' is disabled (set to false), skipping", + feature_ref + ); + continue; + } + + let feature_id = extract_feature_id(feature_ref); + let consecutive_id = format!("{}_{}", feature_id, index); + let feature_dir = build_info.features_content_dir.join(&consecutive_id); + + self.fs.create_dir(&feature_dir).await.map_err(|e| { + log::error!( + "Failed to create feature directory for {}: {e}", + feature_ref + ); + DevContainerError::FilesystemError + })?; + + let oci_ref = parse_oci_feature_ref(feature_ref).ok_or_else(|| { + log::error!( + "Feature '{}' is not a supported OCI feature reference", + feature_ref + ); + DevContainerError::DevContainerParseFailed + })?; + let TokenResponse { token } = + get_oci_token(&oci_ref.registry, &oci_ref.path, &self.http_client) + .await + .map_err(|e| { + log::error!("Failed to get OCI token for feature '{}': {e}", feature_ref); + DevContainerError::ResourceFetchFailed + })?; + let manifest = get_oci_manifest( + &oci_ref.registry, + &oci_ref.path, + &token, + &self.http_client, + &oci_ref.version, + None, + ) + .await + .map_err(|e| { + log::error!( + "Failed to fetch OCI manifest for feature '{}': {e}", + feature_ref + ); + DevContainerError::ResourceFetchFailed + })?; + let digest = &manifest + .layers + .first() + .ok_or_else(|| { + log::error!( + "OCI manifest for feature '{}' contains no layers", + feature_ref + ); + DevContainerError::ResourceFetchFailed + })? + .digest; + download_oci_tarball( + &token, + &oci_ref.registry, + &oci_ref.path, + digest, + "application/vnd.devcontainers.layer.v1+tar", + &feature_dir, + &self.http_client, + &self.fs, + None, + ) + .await?; + + let feature_json_path = &feature_dir.join("devcontainer-feature.json"); + if !self.fs.is_file(feature_json_path).await { + let message = format!( + "No devcontainer-feature.json found in {:?}, no defaults to apply", + feature_json_path + ); + log::error!("{}", &message); + return Err(DevContainerError::ResourceFetchFailed); + } + + let contents = self.fs.load(&feature_json_path).await.map_err(|e| { + log::error!("error reading devcontainer-feature.json: {:?}", e); + DevContainerError::FilesystemError + })?; + + let contents_parsed = self.parse_nonremote_vars_for_content(&contents)?; + + let feature_json: DevContainerFeatureJson = + serde_json_lenient::from_str(&contents_parsed).map_err(|e| { + log::error!("Failed to parse devcontainer-feature.json: {e}"); + DevContainerError::ResourceFetchFailed + })?; + + let feature_manifest = FeatureManifest::new(consecutive_id, feature_dir, feature_json); + + log::debug!("Downloaded OCI feature content for '{}'", feature_ref); + + let env_content = feature_manifest + .write_feature_env(&self.fs, options) + .await?; + + let wrapper_content = generate_install_wrapper(feature_ref, feature_id, &env_content)?; + + self.fs + .write( + &feature_manifest + .file_path() + .join("devcontainer-features-install.sh"), + &wrapper_content.as_bytes(), + ) + .await + .map_err(|e| { + log::error!("Failed to write install wrapper for {}: {e}", feature_ref); + DevContainerError::FilesystemError + })?; + + self.features.push(feature_manifest); + } + + // --- Phase 3: Generate extended Dockerfile from the inflated manifests --- + + let is_compose = dev_container.build_type() == DevContainerBuildType::DockerCompose; + let use_buildkit = self.docker_client.supports_compose_buildkit() || !is_compose; + + let dockerfile_base_content = if let Some(location) = &self.dockerfile_location().await { + self.fs.load(location).await.log_err() + } else { + None + }; + + let dockerfile_content = self.generate_dockerfile_extended( + &container_user, + &remote_user, + dockerfile_base_content, + use_buildkit, + ); + + self.fs + .write(&build_info.dockerfile_path, &dockerfile_content.as_bytes()) + .await + .map_err(|e| { + log::error!("Failed to write Dockerfile.extended: {e}"); + DevContainerError::FilesystemError + })?; + + log::debug!( + "Features build resources written to {:?}", + build_info.features_content_dir + ); + + self.root_image = Some(root_image); + self.features_build_info = Some(build_info); + + Ok(()) + } + + fn generate_dockerfile_extended( + &self, + container_user: &str, + remote_user: &str, + dockerfile_content: Option, + use_buildkit: bool, + ) -> String { + #[cfg(not(target_os = "windows"))] + let update_remote_user_uid = self.dev_container().update_remote_user_uid.unwrap_or(true); + #[cfg(target_os = "windows")] + let update_remote_user_uid = false; + let feature_layers: String = self + .features + .iter() + .map(|manifest| { + manifest.generate_dockerfile_feature_layer( + use_buildkit, + FEATURES_CONTAINER_TEMP_DEST_FOLDER, + ) + }) + .collect(); + + let container_home_cmd = get_ent_passwd_shell_command(container_user); + let remote_home_cmd = get_ent_passwd_shell_command(remote_user); + + let dockerfile_content = dockerfile_content + .map(|content| { + if dockerfile_alias(&content).is_some() { + content + } else { + dockerfile_inject_alias(&content, "dev_container_auto_added_stage_label") + } + }) + .unwrap_or("".to_string()); + + let dest = FEATURES_CONTAINER_TEMP_DEST_FOLDER; + + let feature_content_source_stage = if use_buildkit { + "".to_string() + } else { + "\nFROM dev_container_feature_content_temp as dev_containers_feature_content_source\n" + .to_string() + }; + + let builtin_env_source_path = if use_buildkit { + "./devcontainer-features.builtin.env" + } else { + "/tmp/build-features/devcontainer-features.builtin.env" + }; + + let mut extended_dockerfile = format!( + r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder + +{dockerfile_content} +{feature_content_source_stage} +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize +USER root +COPY --from=dev_containers_feature_content_source {builtin_env_source_path} /tmp/build-features/ +RUN chmod -R 0755 /tmp/build-features/ + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage + +USER root + +RUN mkdir -p {dest} +COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ {dest} + +RUN \ +echo "_CONTAINER_USER_HOME=$({container_home_cmd} | cut -d: -f6)" >> {dest}/devcontainer-features.builtin.env && \ +echo "_REMOTE_USER_HOME=$({remote_home_cmd} | cut -d: -f6)" >> {dest}/devcontainer-features.builtin.env + +{feature_layers} + +ARG _DEV_CONTAINERS_IMAGE_USER=root +USER $_DEV_CONTAINERS_IMAGE_USER +"# + ); + + // If we're not adding a uid update layer, then we should add env vars to this layer instead + if !update_remote_user_uid { + extended_dockerfile = format!( + r#"{extended_dockerfile} +# Ensure that /etc/profile does not clobber the existing path +RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true +"# + ); + + for feature in &self.features { + let container_env_layer = feature.generate_dockerfile_env(); + extended_dockerfile = format!("{extended_dockerfile}\n{container_env_layer}"); + } + + if let Some(env) = &self.dev_container().container_env { + for (key, value) in env { + extended_dockerfile = format!("{extended_dockerfile}ENV {key}={value}\n"); + } + } + } + + extended_dockerfile + } + + fn build_merged_resources( + &self, + base_image: DockerInspect, + ) -> Result { + let dev_container = match &self.config { + ConfigStatus::Deserialized(_) => { + log::error!( + "Dev container has not yet been parsed for variable expansion. Cannot yet merge resources" + ); + return Err(DevContainerError::DevContainerParseFailed); + } + ConfigStatus::VariableParsed(dev_container) => dev_container, + }; + let mut mounts = dev_container.mounts.clone().unwrap_or(Vec::new()); + + let mut feature_mounts = self.features.iter().flat_map(|f| f.mounts()).collect(); + + mounts.append(&mut feature_mounts); + + let privileged = dev_container.privileged.unwrap_or(false) + || self.features.iter().any(|f| f.privileged()); + + let mut entrypoint_script_lines = vec![ + "echo Container started".to_string(), + "trap \"exit 0\" 15".to_string(), + ]; + + for entrypoint in self.features.iter().filter_map(|f| f.entrypoint()) { + entrypoint_script_lines.push(entrypoint.clone()); + } + entrypoint_script_lines.append(&mut vec![ + "exec \"$@\"".to_string(), + "while sleep 1 & wait $!; do :; done".to_string(), + ]); + + Ok(DockerBuildResources { + image: base_image, + additional_mounts: mounts, + privileged, + entrypoint_script: entrypoint_script_lines.join("\n").trim().to_string(), + }) + } + + async fn build_resources(&self) -> Result { + if let ConfigStatus::Deserialized(_) = &self.config { + log::error!( + "Dev container has not yet been parsed for variable expansion. Cannot yet build resources" + ); + return Err(DevContainerError::DevContainerParseFailed); + } + let dev_container = self.dev_container(); + match dev_container.build_type() { + DevContainerBuildType::Image | DevContainerBuildType::Dockerfile => { + let built_docker_image = self.build_docker_image().await?; + let built_docker_image = self + .update_remote_user_uid(built_docker_image, None) + .await?; + + let resources = self.build_merged_resources(built_docker_image)?; + Ok(DevContainerBuildResources::Docker(resources)) + } + DevContainerBuildType::DockerCompose => { + log::debug!("Using docker compose. Building extended compose files"); + let docker_compose_resources = self.build_and_extend_compose_files().await?; + + return Ok(DevContainerBuildResources::DockerCompose( + docker_compose_resources, + )); + } + DevContainerBuildType::None => { + return Err(DevContainerError::DevContainerParseFailed); + } + } + } + + async fn run_dev_container( + &self, + build_resources: DevContainerBuildResources, + ) -> Result { + let ConfigStatus::VariableParsed(_) = &self.config else { + log::error!( + "Variables have not been parsed; cannot proceed with running the dev container" + ); + return Err(DevContainerError::DevContainerParseFailed); + }; + let running_container = match build_resources { + DevContainerBuildResources::DockerCompose(resources) => { + self.run_docker_compose(resources).await? + } + DevContainerBuildResources::Docker(resources) => { + self.run_docker_image(resources).await? + } + }; + + let remote_user = get_remote_user_from_config(&running_container, self)?; + let remote_workspace_folder = get_remote_dir_from_config( + &running_container, + (&self.local_project_directory.display()).to_string(), + )?; + + let remote_env = self.runtime_remote_env(&running_container.config.env_as_map()?)?; + + Ok(DevContainerUp { + container_id: running_container.id, + remote_user, + remote_workspace_folder, + extension_ids: self.extension_ids(), + remote_env, + }) + } + + async fn docker_compose_manifest(&self) -> Result { + let dev_container = match &self.config { + ConfigStatus::Deserialized(_) => { + log::error!( + "Dev container has not yet been parsed for variable expansion. Cannot yet get docker compose files" + ); + return Err(DevContainerError::DevContainerParseFailed); + } + ConfigStatus::VariableParsed(dev_container) => dev_container, + }; + let Some(docker_compose_files) = dev_container.docker_compose_file.clone() else { + return Err(DevContainerError::DevContainerParseFailed); + }; + let docker_compose_full_paths = docker_compose_files + .iter() + .map(|relative| self.config_directory.join(relative)) + .collect::>(); + + let Some(config) = self + .docker_client + .get_docker_compose_config(&docker_compose_full_paths) + .await? + else { + log::error!("Output could not deserialize into DockerComposeConfig"); + return Err(DevContainerError::DevContainerParseFailed); + }; + Ok(DockerComposeResources { + files: docker_compose_full_paths, + config, + }) + } + + async fn build_and_extend_compose_files( + &self, + ) -> Result { + let dev_container = match &self.config { + ConfigStatus::Deserialized(_) => { + log::error!( + "Dev container has not yet been parsed for variable expansion. Cannot yet build from compose files" + ); + return Err(DevContainerError::DevContainerParseFailed); + } + ConfigStatus::VariableParsed(dev_container) => dev_container, + }; + + let Some(features_build_info) = &self.features_build_info else { + log::error!( + "Cannot build and extend compose files: features build info is not yet constructed" + ); + return Err(DevContainerError::DevContainerParseFailed); + }; + let mut docker_compose_resources = self.docker_compose_manifest().await?; + let supports_buildkit = self.docker_client.supports_compose_buildkit(); + + let (main_service_name, main_service) = + find_primary_service(&docker_compose_resources, self)?; + let built_service_image = if main_service + .build + .as_ref() + .map(|b| b.dockerfile.as_ref()) + .is_some() + { + if !supports_buildkit { + self.build_feature_content_image().await?; + } + + let dockerfile_path = &features_build_info.dockerfile_path; + + let build_args = if !supports_buildkit { + HashMap::from([ + ( + "_DEV_CONTAINERS_BASE_IMAGE".to_string(), + "dev_container_auto_added_stage_label".to_string(), + ), + ("_DEV_CONTAINERS_IMAGE_USER".to_string(), "root".to_string()), + ]) + } else { + HashMap::from([ + ("BUILDKIT_INLINE_CACHE".to_string(), "1".to_string()), + ( + "_DEV_CONTAINERS_BASE_IMAGE".to_string(), + "dev_container_auto_added_stage_label".to_string(), + ), + ("_DEV_CONTAINERS_IMAGE_USER".to_string(), "root".to_string()), + ]) + }; + + let additional_contexts = if !supports_buildkit { + None + } else { + Some(HashMap::from([( + "dev_containers_feature_content_source".to_string(), + features_build_info + .features_content_dir + .display() + .to_string(), + )])) + }; + + let build_override = DockerComposeConfig { + name: None, + services: HashMap::from([( + main_service_name.clone(), + DockerComposeService { + image: Some(features_build_info.image_tag.clone()), + entrypoint: None, + cap_add: None, + security_opt: None, + labels: None, + build: Some(DockerComposeServiceBuild { + context: Some( + features_build_info.empty_context_dir.display().to_string(), + ), + dockerfile: Some(dockerfile_path.display().to_string()), + args: Some(build_args), + additional_contexts, + }), + volumes: Vec::new(), + ..Default::default() + }, + )]), + volumes: HashMap::new(), + }; + + let temp_base = std::env::temp_dir().join("devcontainer-zed"); + let config_location = temp_base.join("docker_compose_build.json"); + + let config_json = serde_json_lenient::to_string(&build_override).map_err(|e| { + log::error!("Error serializing docker compose runtime override: {e}"); + DevContainerError::DevContainerParseFailed + })?; + + self.fs + .write(&config_location, config_json.as_bytes()) + .await + .map_err(|e| { + log::error!("Error writing the runtime override file: {e}"); + DevContainerError::FilesystemError + })?; + + docker_compose_resources.files.push(config_location); + + self.docker_client + .docker_compose_build(&docker_compose_resources.files, &self.project_name()) + .await?; + self.docker_client + .inspect(&features_build_info.image_tag) + .await? + } else if let Some(image) = &main_service.image { + if dev_container + .features + .as_ref() + .is_none_or(|features| features.is_empty()) + { + self.docker_client.inspect(image).await? + } else { + if !supports_buildkit { + self.build_feature_content_image().await?; + } + + let dockerfile_path = &features_build_info.dockerfile_path; + + let build_args = if !supports_buildkit { + HashMap::from([ + ("_DEV_CONTAINERS_BASE_IMAGE".to_string(), image.clone()), + ("_DEV_CONTAINERS_IMAGE_USER".to_string(), "root".to_string()), + ]) + } else { + HashMap::from([ + ("BUILDKIT_INLINE_CACHE".to_string(), "1".to_string()), + ("_DEV_CONTAINERS_BASE_IMAGE".to_string(), image.clone()), + ("_DEV_CONTAINERS_IMAGE_USER".to_string(), "root".to_string()), + ]) + }; + + let additional_contexts = if !supports_buildkit { + None + } else { + Some(HashMap::from([( + "dev_containers_feature_content_source".to_string(), + features_build_info + .features_content_dir + .display() + .to_string(), + )])) + }; + + let build_override = DockerComposeConfig { + name: None, + services: HashMap::from([( + main_service_name.clone(), + DockerComposeService { + image: Some(features_build_info.image_tag.clone()), + entrypoint: None, + cap_add: None, + security_opt: None, + labels: None, + build: Some(DockerComposeServiceBuild { + context: Some( + features_build_info.empty_context_dir.display().to_string(), + ), + dockerfile: Some(dockerfile_path.display().to_string()), + args: Some(build_args), + additional_contexts, + }), + volumes: Vec::new(), + ..Default::default() + }, + )]), + volumes: HashMap::new(), + }; + + let temp_base = std::env::temp_dir().join("devcontainer-zed"); + let config_location = temp_base.join("docker_compose_build.json"); + + let config_json = serde_json_lenient::to_string(&build_override).map_err(|e| { + log::error!("Error serializing docker compose runtime override: {e}"); + DevContainerError::DevContainerParseFailed + })?; + + self.fs + .write(&config_location, config_json.as_bytes()) + .await + .map_err(|e| { + log::error!("Error writing the runtime override file: {e}"); + DevContainerError::FilesystemError + })?; + + docker_compose_resources.files.push(config_location); + + self.docker_client + .docker_compose_build(&docker_compose_resources.files, &self.project_name()) + .await?; + + self.docker_client + .inspect(&features_build_info.image_tag) + .await? + } + } else { + log::error!("Docker compose must have either image or dockerfile defined"); + return Err(DevContainerError::DevContainerParseFailed); + }; + + let built_service_image = self + .update_remote_user_uid(built_service_image, Some(&features_build_info.image_tag)) + .await?; + + let resources = self.build_merged_resources(built_service_image)?; + + let network_mode = main_service.network_mode.as_ref(); + let network_mode_service = network_mode.and_then(|mode| mode.strip_prefix("service:")); + let runtime_override_file = self + .write_runtime_override_file(&main_service_name, network_mode_service, resources) + .await?; + + docker_compose_resources.files.push(runtime_override_file); + + Ok(docker_compose_resources) + } + + async fn write_runtime_override_file( + &self, + main_service_name: &str, + network_mode_service: Option<&str>, + resources: DockerBuildResources, + ) -> Result { + let config = + self.build_runtime_override(main_service_name, network_mode_service, resources)?; + let temp_base = std::env::temp_dir().join("devcontainer-zed"); + let config_location = temp_base.join("docker_compose_runtime.json"); + + let config_json = serde_json_lenient::to_string(&config).map_err(|e| { + log::error!("Error serializing docker compose runtime override: {e}"); + DevContainerError::DevContainerParseFailed + })?; + + self.fs + .write(&config_location, config_json.as_bytes()) + .await + .map_err(|e| { + log::error!("Error writing the runtime override file: {e}"); + DevContainerError::FilesystemError + })?; + + Ok(config_location) + } + + fn build_runtime_override( + &self, + main_service_name: &str, + network_mode_service: Option<&str>, + resources: DockerBuildResources, + ) -> Result { + let mut runtime_labels = vec![]; + + if let Some(metadata) = &resources.image.config.labels.metadata { + let serialized_metadata = serde_json_lenient::to_string(metadata).map_err(|e| { + log::error!("Error serializing docker image metadata: {e}"); + DevContainerError::ContainerNotValid(resources.image.id.clone()) + })?; + + runtime_labels.push(format!( + "{}={}", + "devcontainer.metadata", serialized_metadata + )); + } + + for (k, v) in self.identifying_labels() { + runtime_labels.push(format!("{}={}", k, v)); + } + + let config_volumes: HashMap = resources + .additional_mounts + .iter() + .filter_map(|mount| { + if let Some(mount_type) = &mount.mount_type + && mount_type.to_lowercase() == "volume" + { + Some(( + mount.source.clone(), + DockerComposeVolume { + name: mount.source.clone(), + }, + )) + } else { + None + } + }) + .collect(); + + let volumes: Vec = resources + .additional_mounts + .iter() + .map(|v| MountDefinition { + source: v.source.clone(), + target: v.target.clone(), + mount_type: v.mount_type.clone(), + }) + .collect(); + + let mut main_service = DockerComposeService { + entrypoint: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + resources.entrypoint_script, + "-".to_string(), + ]), + cap_add: Some(vec!["SYS_PTRACE".to_string()]), + security_opt: Some(vec!["seccomp=unconfined".to_string()]), + labels: Some(runtime_labels), + volumes, + privileged: Some(resources.privileged), + ..Default::default() + }; + // let mut extra_service_port_declarations: Vec<(String, DockerComposeService)> = Vec::new(); + let mut service_declarations: HashMap = HashMap::new(); + if let Some(forward_ports) = &self.dev_container().forward_ports { + let main_service_ports: Vec = forward_ports + .iter() + .filter_map(|f| match f { + ForwardPort::Number(port) => Some(port.to_string()), + ForwardPort::String(port) => { + let parts: Vec<&str> = port.split(":").collect(); + if parts.len() <= 1 { + Some(port.to_string()) + } else if parts.len() == 2 { + if parts[0] == main_service_name { + Some(parts[1].to_string()) + } else { + None + } + } else { + None + } + } + }) + .collect(); + for port in main_service_ports { + // If the main service uses a different service's network bridge, append to that service's ports instead + if let Some(network_service_name) = network_mode_service { + if let Some(service) = service_declarations.get_mut(network_service_name) { + service.ports.push(format!("{port}:{port}")); + } else { + service_declarations.insert( + network_service_name.to_string(), + DockerComposeService { + ports: vec![format!("{port}:{port}")], + ..Default::default() + }, + ); + } + } else { + main_service.ports.push(format!("{port}:{port}")); + } + } + let other_service_ports: Vec<(&str, &str)> = forward_ports + .iter() + .filter_map(|f| match f { + ForwardPort::Number(_) => None, + ForwardPort::String(port) => { + let parts: Vec<&str> = port.split(":").collect(); + if parts.len() != 2 { + None + } else { + if parts[0] == main_service_name { + None + } else { + Some((parts[0], parts[1])) + } + } + } + }) + .collect(); + for (service_name, port) in other_service_ports { + if let Some(service) = service_declarations.get_mut(service_name) { + service.ports.push(format!("{port}:{port}")); + } else { + service_declarations.insert( + service_name.to_string(), + DockerComposeService { + ports: vec![format!("{port}:{port}")], + ..Default::default() + }, + ); + } + } + } + if let Some(port) = &self.dev_container().app_port { + if let Some(network_service_name) = network_mode_service { + if let Some(service) = service_declarations.get_mut(network_service_name) { + service.ports.push(format!("{port}:{port}")); + } else { + service_declarations.insert( + network_service_name.to_string(), + DockerComposeService { + ports: vec![format!("{port}:{port}")], + ..Default::default() + }, + ); + } + } else { + main_service.ports.push(format!("{port}:{port}")); + } + } + + service_declarations.insert(main_service_name.to_string(), main_service); + let new_docker_compose_config = DockerComposeConfig { + name: None, + services: service_declarations, + volumes: config_volumes, + }; + + Ok(new_docker_compose_config) + } + + async fn build_docker_image(&self) -> Result { + let dev_container = match &self.config { + ConfigStatus::Deserialized(_) => { + log::error!( + "Dev container has not yet been parsed for variable expansion. Cannot yet build image" + ); + return Err(DevContainerError::DevContainerParseFailed); + } + ConfigStatus::VariableParsed(dev_container) => dev_container, + }; + + match dev_container.build_type() { + DevContainerBuildType::Image => { + let Some(image_tag) = &dev_container.image else { + return Err(DevContainerError::DevContainerParseFailed); + }; + let base_image = self.docker_client.inspect(image_tag).await?; + if dev_container + .features + .as_ref() + .is_none_or(|features| features.is_empty()) + { + log::debug!("No features to add. Using base image"); + return Ok(base_image); + } + } + DevContainerBuildType::Dockerfile => {} + DevContainerBuildType::DockerCompose | DevContainerBuildType::None => { + return Err(DevContainerError::DevContainerParseFailed); + } + }; + + let mut command = self.create_docker_build()?; + + let output = self + .command_runner + .run_command(&mut command) + .await + .map_err(|e| { + log::error!("Error building docker image: {e}"); + DevContainerError::CommandFailed(command.get_program().display().to_string()) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + log::error!("docker buildx build failed: {stderr}"); + return Err(DevContainerError::CommandFailed( + command.get_program().display().to_string(), + )); + } + + // After a successful build, inspect the newly tagged image to get its metadata + let Some(features_build_info) = &self.features_build_info else { + log::error!("Features build info expected, but not created"); + return Err(DevContainerError::DevContainerParseFailed); + }; + let image = self + .docker_client + .inspect(&features_build_info.image_tag) + .await?; + + Ok(image) + } + + #[cfg(target_os = "windows")] + async fn update_remote_user_uid( + &self, + image: DockerInspect, + _override_tag: Option<&str>, + ) -> Result { + Ok(image) + } + #[cfg(not(target_os = "windows"))] + async fn update_remote_user_uid( + &self, + image: DockerInspect, + override_tag: Option<&str>, + ) -> Result { + let dev_container = self.dev_container(); + + let Some(features_build_info) = &self.features_build_info else { + return Ok(image); + }; + + // updateRemoteUserUID defaults to true per the devcontainers spec + if dev_container.update_remote_user_uid == Some(false) { + return Ok(image); + } + + let remote_user = get_remote_user_from_config(&image, self)?; + if remote_user == "root" || remote_user.chars().all(|c| c.is_ascii_digit()) { + return Ok(image); + } + + let image_user = image + .config + .image_user + .as_deref() + .unwrap_or("root") + .to_string(); + + let host_uid = Command::new("id") + .arg("-u") + .output() + .await + .map_err(|e| { + log::error!("Failed to get host UID: {e}"); + DevContainerError::CommandFailed("id -u".to_string()) + }) + .and_then(|output| { + String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .map_err(|e| { + log::error!("Failed to parse host UID: {e}"); + DevContainerError::CommandFailed("id -u".to_string()) + }) + })?; + + let host_gid = Command::new("id") + .arg("-g") + .output() + .await + .map_err(|e| { + log::error!("Failed to get host GID: {e}"); + DevContainerError::CommandFailed("id -g".to_string()) + }) + .and_then(|output| { + String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .map_err(|e| { + log::error!("Failed to parse host GID: {e}"); + DevContainerError::CommandFailed("id -g".to_string()) + }) + })?; + + let dockerfile_content = self.generate_update_uid_dockerfile(); + + let dockerfile_path = features_build_info + .features_content_dir + .join("updateUID.Dockerfile"); + self.fs + .write(&dockerfile_path, dockerfile_content.as_bytes()) + .await + .map_err(|e| { + log::error!("Failed to write updateUID Dockerfile: {e}"); + DevContainerError::FilesystemError + })?; + + let updated_image_tag = override_tag + .map(|t| t.to_string()) + .unwrap_or_else(|| format!("{}-uid", features_build_info.image_tag)); + + let mut command = Command::new(self.docker_client.docker_cli()); + command.args(["build"]); + command.args(["-f", &dockerfile_path.display().to_string()]); + command.args(["-t", &updated_image_tag]); + command.args([ + "--build-arg", + &format!("BASE_IMAGE={}", features_build_info.image_tag), + ]); + command.args(["--build-arg", &format!("REMOTE_USER={}", remote_user)]); + command.args(["--build-arg", &format!("NEW_UID={}", host_uid)]); + command.args(["--build-arg", &format!("NEW_GID={}", host_gid)]); + command.args(["--build-arg", &format!("IMAGE_USER={}", image_user)]); + command.arg(features_build_info.empty_context_dir.display().to_string()); + + let output = self + .command_runner + .run_command(&mut command) + .await + .map_err(|e| { + log::error!("Error building UID update image: {e}"); + DevContainerError::CommandFailed(command.get_program().display().to_string()) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + log::error!("UID update build failed: {stderr}"); + return Err(DevContainerError::CommandFailed( + command.get_program().display().to_string(), + )); + } + + self.docker_client.inspect(&updated_image_tag).await + } + + #[cfg(not(target_os = "windows"))] + fn generate_update_uid_dockerfile(&self) -> String { + let mut dockerfile = r#"ARG BASE_IMAGE +FROM $BASE_IMAGE + +USER root + +ARG REMOTE_USER +ARG NEW_UID +ARG NEW_GID +SHELL ["/bin/sh", "-c"] +RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/OLD_UID=\1;OLD_GID=\2;HOME_FOLDER=\3/p" /etc/passwd); \ + eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_UID}:.*/EXISTING_USER=\1/p" /etc/passwd); \ + eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_GID}:.*/EXISTING_GROUP=\1/p" /etc/group); \ + if [ -z "$OLD_UID" ]; then \ + echo "Remote user not found in /etc/passwd ($REMOTE_USER)."; \ + elif [ "$OLD_UID" = "$NEW_UID" -a "$OLD_GID" = "$NEW_GID" ]; then \ + echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \ + elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \ + echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \ + else \ + if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \ + FREE_GID=65532; \ + while grep -q ":[^:]*:${FREE_GID}:" /etc/group; do FREE_GID=$((FREE_GID - 1)); done; \ + echo "Reassigning group $EXISTING_GROUP from GID $NEW_GID to $FREE_GID."; \ + sed -i -e "s/\(${EXISTING_GROUP}:[^:]*:\)${NEW_GID}:/\1${FREE_GID}:/" /etc/group; \ + fi; \ + echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \ + sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \ + if [ "$OLD_GID" != "$NEW_GID" ]; then \ + sed -i -e "s/\([^:]*:[^:]*:\)${OLD_GID}:/\1${NEW_GID}:/" /etc/group; \ + fi; \ + chown -R $NEW_UID:$NEW_GID $HOME_FOLDER; \ + fi; + +ARG IMAGE_USER +USER $IMAGE_USER + +# Ensure that /etc/profile does not clobber the existing path +RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true +"#.to_string(); + for feature in &self.features { + let container_env_layer = feature.generate_dockerfile_env(); + dockerfile = format!("{dockerfile}\n{container_env_layer}"); + } + + if let Some(env) = &self.dev_container().container_env { + for (key, value) in env { + dockerfile = format!("{dockerfile}ENV {key}={value}\n"); + } + } + dockerfile + } + + async fn build_feature_content_image(&self) -> Result<(), DevContainerError> { + let Some(features_build_info) = &self.features_build_info else { + log::error!("Features build info not available for building feature content image"); + return Err(DevContainerError::DevContainerParseFailed); + }; + let features_content_dir = &features_build_info.features_content_dir; + + let dockerfile_content = "FROM scratch\nCOPY . /tmp/build-features/\n"; + let dockerfile_path = features_content_dir.join("Dockerfile.feature-content"); + + self.fs + .write(&dockerfile_path, dockerfile_content.as_bytes()) + .await + .map_err(|e| { + log::error!("Failed to write feature content Dockerfile: {e}"); + DevContainerError::FilesystemError + })?; + + let mut command = Command::new(self.docker_client.docker_cli()); + command.args([ + "build", + "-t", + "dev_container_feature_content_temp", + "-f", + &dockerfile_path.display().to_string(), + &features_content_dir.display().to_string(), + ]); + + let output = self + .command_runner + .run_command(&mut command) + .await + .map_err(|e| { + log::error!("Error building feature content image: {e}"); + DevContainerError::CommandFailed(self.docker_client.docker_cli()) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + log::error!("Feature content image build failed: {stderr}"); + return Err(DevContainerError::CommandFailed( + self.docker_client.docker_cli(), + )); + } + + Ok(()) + } + + fn create_docker_build(&self) -> Result { + let dev_container = match &self.config { + ConfigStatus::Deserialized(_) => { + log::error!( + "Dev container has not yet been parsed for variable expansion. Cannot yet proceed with docker build" + ); + return Err(DevContainerError::DevContainerParseFailed); + } + ConfigStatus::VariableParsed(dev_container) => dev_container, + }; + + let Some(features_build_info) = &self.features_build_info else { + log::error!( + "Cannot create docker build command; features build info has not been constructed" + ); + return Err(DevContainerError::DevContainerParseFailed); + }; + let mut command = Command::new(self.docker_client.docker_cli()); + + command.args(["buildx", "build"]); + + // --load is short for --output=docker, loading the built image into the local docker images + command.arg("--load"); + + // BuildKit build context: provides the features content directory as a named context + // that the Dockerfile.extended can COPY from via `--from=dev_containers_feature_content_source` + command.args([ + "--build-context", + &format!( + "dev_containers_feature_content_source={}", + features_build_info.features_content_dir.display() + ), + ]); + + // Build args matching the CLI reference implementation's `getFeaturesBuildOptions` + if let Some(build_image) = &features_build_info.build_image { + command.args([ + "--build-arg", + &format!("_DEV_CONTAINERS_BASE_IMAGE={}", build_image), + ]); + } else { + command.args([ + "--build-arg", + "_DEV_CONTAINERS_BASE_IMAGE=dev_container_auto_added_stage_label", + ]); + } + + command.args([ + "--build-arg", + &format!( + "_DEV_CONTAINERS_IMAGE_USER={}", + self.root_image + .as_ref() + .and_then(|docker_image| docker_image.config.image_user.as_ref()) + .unwrap_or(&"root".to_string()) + ), + ]); + + command.args([ + "--build-arg", + "_DEV_CONTAINERS_FEATURE_CONTENT_SOURCE=dev_container_feature_content_temp", + ]); + + if let Some(args) = dev_container.build.as_ref().and_then(|b| b.args.as_ref()) { + for (key, value) in args { + command.args(["--build-arg", &format!("{}={}", key, value)]); + } + } + + command.args(["--target", "dev_containers_target_stage"]); + + command.args([ + "-f", + &features_build_info.dockerfile_path.display().to_string(), + ]); + + command.args(["-t", &features_build_info.image_tag]); + + if dev_container.build_type() == DevContainerBuildType::Dockerfile { + command.arg(self.config_directory.display().to_string()); + } else { + // Use an empty folder as the build context to avoid pulling in unneeded files. + // The actual feature content is supplied via the BuildKit build context above. + command.arg(features_build_info.empty_context_dir.display().to_string()); + } + + Ok(command) + } + + async fn run_docker_compose( + &self, + resources: DockerComposeResources, + ) -> Result { + let mut command = Command::new(self.docker_client.docker_cli()); + command.args(&["compose", "--project-name", &self.project_name()]); + for docker_compose_file in resources.files { + command.args(&["-f", &docker_compose_file.display().to_string()]); + } + command.args(&["up", "-d"]); + + let output = self + .command_runner + .run_command(&mut command) + .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(), + )); + } + + if let Some(docker_ps) = self.check_for_existing_container().await? { + log::debug!("Found newly created dev container"); + return self.docker_client.inspect(&docker_ps.id).await; + } + + log::error!("Could not find existing container after docker compose up"); + + Err(DevContainerError::DevContainerParseFailed) + } + + async fn run_docker_image( + &self, + build_resources: DockerBuildResources, + ) -> Result { + let mut docker_run_command = self.create_docker_run_command(build_resources)?; + + let output = self + .command_runner + .run_command(&mut docker_run_command) + .await + .map_err(|e| { + log::error!("Error running docker run: {e}"); + DevContainerError::CommandFailed( + docker_run_command.get_program().display().to_string(), + ) + })?; + + if !output.status.success() { + let std_err = String::from_utf8_lossy(&output.stderr); + log::error!("Non-success status from docker run. StdErr: {std_err}"); + return Err(DevContainerError::CommandFailed( + docker_run_command.get_program().display().to_string(), + )); + } + + log::debug!("Checking for container that was started"); + let Some(docker_ps) = self.check_for_existing_container().await? else { + log::error!("Could not locate container just created"); + return Err(DevContainerError::DevContainerParseFailed); + }; + self.docker_client.inspect(&docker_ps.id).await + } + + fn local_workspace_folder(&self) -> String { + self.local_project_directory.display().to_string() + } + fn local_workspace_base_name(&self) -> Result { + self.local_project_directory + .file_name() + .map(|f| f.display().to_string()) + .ok_or(DevContainerError::DevContainerParseFailed) + } + + fn remote_workspace_folder(&self) -> Result { + self.dev_container() + .workspace_folder + .as_ref() + .map(|folder| PathBuf::from(folder)) + .or(Some( + PathBuf::from(DEFAULT_REMOTE_PROJECT_DIR).join(self.local_workspace_base_name()?), + )) + .ok_or(DevContainerError::DevContainerParseFailed) + } + fn remote_workspace_base_name(&self) -> Result { + self.remote_workspace_folder().and_then(|f| { + f.file_name() + .map(|file_name| file_name.display().to_string()) + .ok_or(DevContainerError::DevContainerParseFailed) + }) + } + + fn remote_workspace_mount(&self) -> Result { + if let Some(mount) = &self.dev_container().workspace_mount { + return Ok(mount.clone()); + } + let Some(project_directory_name) = self.local_project_directory.file_name() else { + return Err(DevContainerError::DevContainerParseFailed); + }; + + Ok(MountDefinition { + source: self.local_workspace_folder(), + target: format!("/workspaces/{}", project_directory_name.display()), + mount_type: None, + }) + } + + fn create_docker_run_command( + &self, + build_resources: DockerBuildResources, + ) -> Result { + let remote_workspace_mount = self.remote_workspace_mount()?; + + let docker_cli = self.docker_client.docker_cli(); + let mut command = Command::new(&docker_cli); + + command.arg("run"); + + if build_resources.privileged { + command.arg("--privileged"); + } + + if &docker_cli == "podman" { + command.args(&["--security-opt", "label=disable", "--userns=keep-id"]); + } + + command.arg("--sig-proxy=false"); + command.arg("-d"); + command.arg("--mount"); + command.arg(remote_workspace_mount.to_string()); + + for mount in &build_resources.additional_mounts { + command.arg("--mount"); + command.arg(mount.to_string()); + } + + for (key, val) in self.identifying_labels() { + command.arg("-l"); + command.arg(format!("{}={}", key, val)); + } + + if let Some(metadata) = &build_resources.image.config.labels.metadata { + let serialized_metadata = serde_json_lenient::to_string(metadata).map_err(|e| { + log::error!("Problem serializing image metadata: {e}"); + DevContainerError::ContainerNotValid(build_resources.image.id.clone()) + })?; + command.arg("-l"); + command.arg(format!( + "{}={}", + "devcontainer.metadata", serialized_metadata + )); + } + + if let Some(forward_ports) = &self.dev_container().forward_ports { + for port in forward_ports { + if let ForwardPort::Number(port_number) = port { + command.arg("-p"); + command.arg(format!("{port_number}:{port_number}")); + } + } + } + if let Some(app_port) = &self.dev_container().app_port { + command.arg("-p"); + command.arg(format!("{app_port}:{app_port}")); + } + + command.arg("--entrypoint"); + command.arg("/bin/sh"); + command.arg(&build_resources.image.id); + command.arg("-c"); + + command.arg(build_resources.entrypoint_script); + command.arg("-"); + + Ok(command) + } + + fn extension_ids(&self) -> Vec { + self.dev_container() + .customizations + .as_ref() + .map(|c| c.zed.extensions.clone()) + .unwrap_or_default() + } + + async fn build_and_run(&mut self) -> Result { + self.run_initialize_commands().await?; + + self.download_feature_and_dockerfile_resources().await?; + + let build_resources = self.build_resources().await?; + + let devcontainer_up = self.run_dev_container(build_resources).await?; + + self.run_remote_scripts(&devcontainer_up, true).await?; + + Ok(devcontainer_up) + } + + async fn run_remote_scripts( + &self, + devcontainer_up: &DevContainerUp, + new_container: bool, + ) -> Result<(), DevContainerError> { + let ConfigStatus::VariableParsed(config) = &self.config else { + log::error!("Config not yet parsed, cannot proceed with remote scripts"); + return Err(DevContainerError::DevContainerScriptsFailed); + }; + let remote_folder = self.remote_workspace_folder()?.display().to_string(); + + if new_container { + if let Some(on_create_command) = &config.on_create_command { + for (command_name, command) in on_create_command.script_commands() { + log::debug!("Running on create command {command_name}"); + self.docker_client + .run_docker_exec( + &devcontainer_up.container_id, + &remote_folder, + "root", + &devcontainer_up.remote_env, + command, + ) + .await?; + } + } + if let Some(update_content_command) = &config.update_content_command { + for (command_name, command) in update_content_command.script_commands() { + log::debug!("Running update content command {command_name}"); + self.docker_client + .run_docker_exec( + &devcontainer_up.container_id, + &remote_folder, + "root", + &devcontainer_up.remote_env, + command, + ) + .await?; + } + } + + if let Some(post_create_command) = &config.post_create_command { + for (command_name, command) in post_create_command.script_commands() { + log::debug!("Running post create command {command_name}"); + self.docker_client + .run_docker_exec( + &devcontainer_up.container_id, + &remote_folder, + &devcontainer_up.remote_user, + &devcontainer_up.remote_env, + command, + ) + .await?; + } + } + if let Some(post_start_command) = &config.post_start_command { + for (command_name, command) in post_start_command.script_commands() { + log::debug!("Running post start command {command_name}"); + self.docker_client + .run_docker_exec( + &devcontainer_up.container_id, + &remote_folder, + &devcontainer_up.remote_user, + &devcontainer_up.remote_env, + command, + ) + .await?; + } + } + } + if let Some(post_attach_command) = &config.post_attach_command { + for (command_name, command) in post_attach_command.script_commands() { + log::debug!("Running post attach command {command_name}"); + self.docker_client + .run_docker_exec( + &devcontainer_up.container_id, + &remote_folder, + &devcontainer_up.remote_user, + &devcontainer_up.remote_env, + command, + ) + .await?; + } + } + + Ok(()) + } + + async fn run_initialize_commands(&self) -> Result<(), DevContainerError> { + let ConfigStatus::VariableParsed(config) = &self.config else { + log::error!("Config not yet parsed, cannot proceed with initializeCommand"); + return Err(DevContainerError::DevContainerParseFailed); + }; + + if let Some(initialize_command) = &config.initialize_command { + log::debug!("Running initialize command"); + initialize_command + .run(&self.command_runner, &self.local_project_directory) + .await + } else { + log::warn!("No initialize command found"); + Ok(()) + } + } + + async fn check_for_existing_devcontainer( + &self, + ) -> Result, DevContainerError> { + if let Some(docker_ps) = self.check_for_existing_container().await? { + log::debug!("Dev container already found. Proceeding with it"); + + let docker_inspect = self.docker_client.inspect(&docker_ps.id).await?; + + if !docker_inspect.is_running() { + log::debug!("Container not running. Will attempt to start, and then proceed"); + self.docker_client.start_container(&docker_ps.id).await?; + } + + let remote_user = get_remote_user_from_config(&docker_inspect, self)?; + + let remote_folder = get_remote_dir_from_config( + &docker_inspect, + (&self.local_project_directory.display()).to_string(), + )?; + + let remote_env = self.runtime_remote_env(&docker_inspect.config.env_as_map()?)?; + + let dev_container_up = DevContainerUp { + container_id: docker_ps.id, + remote_user: remote_user, + remote_workspace_folder: remote_folder, + extension_ids: self.extension_ids(), + remote_env, + }; + + self.run_remote_scripts(&dev_container_up, false).await?; + + Ok(Some(dev_container_up)) + } else { + log::debug!("Existing container not found."); + + Ok(None) + } + } + + async fn check_for_existing_container(&self) -> Result, DevContainerError> { + self.docker_client + .find_process_by_filters( + self.identifying_labels() + .iter() + .map(|(k, v)| format!("label={k}={v}")) + .collect(), + ) + .await + } + + fn project_name(&self) -> String { + if let Some(name) = &self.dev_container().name { + safe_id_lower(name) + } else { + let alternate_name = &self + .local_workspace_base_name() + .unwrap_or(self.local_workspace_folder()); + safe_id_lower(alternate_name) + } + } +} + +/// Holds all the information needed to construct a `docker buildx build` command +/// that extends a base image with dev container features. +/// +/// This mirrors the `ImageBuildOptions` interface in the CLI reference implementation +/// (cli/src/spec-node/containerFeatures.ts). +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct FeaturesBuildInfo { + /// Path to the generated Dockerfile.extended + pub dockerfile_path: PathBuf, + /// Path to the features content directory (used as a BuildKit build context) + pub features_content_dir: PathBuf, + /// Path to an empty directory used as the Docker build context + pub empty_context_dir: PathBuf, + /// The base image name (e.g. "mcr.microsoft.com/devcontainers/rust:2-1-bookworm") + pub build_image: Option, + /// The tag to apply to the built image (e.g. "vsc-myproject-features") + pub image_tag: String, +} + +pub(crate) async fn read_devcontainer_configuration( + config: DevContainerConfig, + context: &DevContainerContext, + environment: HashMap, +) -> Result { + let docker = if context.use_podman { + Docker::new("podman") + } else { + Docker::new("docker") + }; + let mut dev_container = DevContainerManifest::new( + context, + environment, + Arc::new(docker), + Arc::new(DefaultCommandRunner::new()), + config, + &context.project_directory.as_ref(), + ) + .await?; + dev_container.parse_nonremote_vars()?; + Ok(dev_container.dev_container().clone()) +} + +pub(crate) async fn spawn_dev_container( + context: &DevContainerContext, + environment: HashMap, + config: DevContainerConfig, + local_project_path: &Path, +) -> Result { + let docker = if context.use_podman { + Docker::new("podman") + } else { + Docker::new("docker") + }; + let mut devcontainer_manifest = DevContainerManifest::new( + context, + environment, + Arc::new(docker), + Arc::new(DefaultCommandRunner::new()), + config, + local_project_path, + ) + .await?; + + devcontainer_manifest.parse_nonremote_vars()?; + + log::debug!("Checking for existing container"); + if let Some(devcontainer) = devcontainer_manifest + .check_for_existing_devcontainer() + .await? + { + Ok(devcontainer) + } else { + log::debug!("Existing container not found. Building"); + + devcontainer_manifest.build_and_run().await + } +} + +#[derive(Debug)] +struct DockerBuildResources { + image: DockerInspect, + additional_mounts: Vec, + privileged: bool, + entrypoint_script: String, +} + +#[derive(Debug)] +enum DevContainerBuildResources { + DockerCompose(DockerComposeResources), + Docker(DockerBuildResources), +} + +fn find_primary_service( + docker_compose: &DockerComposeResources, + devcontainer: &DevContainerManifest, +) -> Result<(String, DockerComposeService), DevContainerError> { + let Some(service_name) = &devcontainer.dev_container().service else { + return Err(DevContainerError::DevContainerParseFailed); + }; + + match docker_compose.config.services.get(service_name) { + Some(service) => Ok((service_name.clone(), service.clone())), + None => Err(DevContainerError::DevContainerParseFailed), + } +} + +/// Destination folder inside the container where feature content is staged during build. +/// Mirrors the CLI's `FEATURES_CONTAINER_TEMP_DEST_FOLDER`. +const FEATURES_CONTAINER_TEMP_DEST_FOLDER: &str = "/tmp/dev-container-features"; + +/// Escapes regex special characters in a string. +fn escape_regex_chars(input: &str) -> String { + let mut result = String::with_capacity(input.len() * 2); + for c in input.chars() { + if ".*+?^${}()|[]\\".contains(c) { + result.push('\\'); + } + result.push(c); + } + result +} + +/// Extracts the short feature ID from a full feature reference string. +/// +/// Examples: +/// - `ghcr.io/devcontainers/features/aws-cli:1` → `aws-cli` +/// - `ghcr.io/user/repo/go` → `go` +/// - `ghcr.io/devcontainers/features/rust@sha256:abc` → `rust` +/// - `./myFeature` → `myFeature` +fn extract_feature_id(feature_ref: &str) -> &str { + let without_version = if let Some(at_idx) = feature_ref.rfind('@') { + &feature_ref[..at_idx] + } else { + let last_slash = feature_ref.rfind('/'); + let last_colon = feature_ref.rfind(':'); + match (last_slash, last_colon) { + (Some(slash), Some(colon)) if colon > slash => &feature_ref[..colon], + _ => feature_ref, + } + }; + match without_version.rfind('/') { + Some(idx) => &without_version[idx + 1..], + None => without_version, + } +} + +/// Generates a shell command that looks up a user's passwd entry. +/// +/// Mirrors the CLI's `getEntPasswdShellCommand` in `commonUtils.ts`. +/// Tries `getent passwd` first, then falls back to grepping `/etc/passwd`. +fn get_ent_passwd_shell_command(user: &str) -> String { + let escaped_for_shell = user.replace('\\', "\\\\").replace('\'', "\\'"); + let escaped_for_regex = escape_regex_chars(user).replace('\'', "\\'"); + format!( + " (command -v getent >/dev/null 2>&1 && getent passwd '{shell}' || grep -E '^{re}|^[^:]*:[^:]*:{re}:' /etc/passwd || true)", + shell = escaped_for_shell, + re = escaped_for_regex, + ) +} + +/// Determines feature installation order, respecting `overrideFeatureInstallOrder`. +/// +/// Features listed in the override come first (in the specified order), followed +/// by any remaining features sorted lexicographically by their full reference ID. +fn resolve_feature_order<'a>( + features: &'a HashMap, + override_order: &Option>, +) -> Vec<(&'a String, &'a FeatureOptions)> { + if let Some(order) = override_order { + let mut ordered: Vec<(&'a String, &'a FeatureOptions)> = Vec::new(); + for ordered_id in order { + if let Some((key, options)) = features.get_key_value(ordered_id) { + ordered.push((key, options)); + } + } + let mut remaining: Vec<_> = features + .iter() + .filter(|(id, _)| !order.iter().any(|o| o == *id)) + .collect(); + remaining.sort_by_key(|(id, _)| id.as_str()); + ordered.extend(remaining); + ordered + } else { + let mut entries: Vec<_> = features.iter().collect(); + entries.sort_by_key(|(id, _)| id.as_str()); + entries + } +} + +/// Generates the `devcontainer-features-install.sh` wrapper script for one feature. +/// +/// Mirrors the CLI's `getFeatureInstallWrapperScript` in +/// `containerFeaturesConfiguration.ts`. +fn generate_install_wrapper( + feature_ref: &str, + feature_id: &str, + env_variables: &str, +) -> Result { + let escaped_id = shlex::try_quote(feature_ref).map_err(|e| { + log::error!("Error escaping feature ref {feature_ref}: {e}"); + DevContainerError::DevContainerParseFailed + })?; + let escaped_name = shlex::try_quote(feature_id).map_err(|e| { + log::error!("Error escaping feature {feature_id}: {e}"); + DevContainerError::DevContainerParseFailed + })?; + let options_indented: String = env_variables + .lines() + .filter(|l| !l.is_empty()) + .map(|l| format!(" {}", l)) + .collect::>() + .join("\n"); + let escaped_options = shlex::try_quote(&options_indented).map_err(|e| { + log::error!("Error escaping options {options_indented}: {e}"); + DevContainerError::DevContainerParseFailed + })?; + + let script = format!( + r#"#!/bin/sh +set -e + +on_exit () {{ + [ $? -eq 0 ] && exit + echo 'ERROR: Feature "{escaped_name}" ({escaped_id}) failed to install!' +}} + +trap on_exit EXIT + +echo =========================================================================== +echo 'Feature : {escaped_name}' +echo 'Id : {escaped_id}' +echo 'Options :' +echo {escaped_options} +echo =========================================================================== + +set -a +. ../devcontainer-features.builtin.env +. ./devcontainer-features.env +set +a + +chmod +x ./install.sh +./install.sh +"# + ); + + Ok(script) +} + +// Dockerfile actions need to be moved to their own file +fn dockerfile_alias(dockerfile_content: &str) -> Option { + dockerfile_content + .lines() + .find(|line| line.starts_with("FROM")) + .and_then(|line| { + let words: Vec<&str> = line.split(" ").collect(); + if words.len() > 2 && words[words.len() - 2].to_lowercase() == "as" { + return Some(words[words.len() - 1].to_string()); + } else { + return None; + } + }) +} + +fn dockerfile_inject_alias(dockerfile_content: &str, alias: &str) -> String { + if dockerfile_alias(dockerfile_content).is_some() { + dockerfile_content.to_string() + } else { + dockerfile_content + .lines() + .map(|line| { + if line.starts_with("FROM") { + format!("{} AS {}", line, alias) + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") + } +} + +fn image_from_dockerfile( + devcontainer: &DevContainerManifest, + dockerfile_contents: String, +) -> Result { + let mut raw_contents = dockerfile_contents + .lines() + .find(|line| line.starts_with("FROM")) + .and_then(|from_line| { + from_line + .split(' ') + .collect::>() + .get(1) + .map(|s| s.to_string()) + }) + .ok_or_else(|| { + log::error!("Could not find an image definition in dockerfile"); + DevContainerError::DevContainerParseFailed + })?; + + for (k, v) in devcontainer + .dev_container() + .build + .as_ref() + .and_then(|b| b.args.as_ref()) + .unwrap_or(&HashMap::new()) + { + raw_contents = raw_contents.replace(&format!("${{{}}}", k), v); + } + Ok(raw_contents) +} + +// Container user things +// This should come from spec - see the docs +fn get_remote_user_from_config( + docker_config: &DockerInspect, + devcontainer: &DevContainerManifest, +) -> Result { + if let DevContainer { + remote_user: Some(user), + .. + } = &devcontainer.dev_container() + { + return Ok(user.clone()); + } + let Some(metadata) = &docker_config.config.labels.metadata else { + log::error!("Could not locate metadata"); + return Err(DevContainerError::ContainerNotValid( + docker_config.id.clone(), + )); + }; + for metadatum in metadata { + if let Some(remote_user) = metadatum.get("remoteUser") { + if let Some(remote_user_str) = remote_user.as_str() { + return Ok(remote_user_str.to_string()); + } + } + } + log::error!("Could not locate the remote user"); + Err(DevContainerError::ContainerNotValid( + docker_config.id.clone(), + )) +} + +// This should come from spec - see the docs +fn get_container_user_from_config( + docker_config: &DockerInspect, + devcontainer: &DevContainerManifest, +) -> Result { + if let Some(user) = &devcontainer.dev_container().container_user { + return Ok(user.to_string()); + } + if let Some(metadata) = &docker_config.config.labels.metadata { + for metadatum in metadata { + if let Some(container_user) = metadatum.get("containerUser") { + if let Some(container_user_str) = container_user.as_str() { + return Ok(container_user_str.to_string()); + } + } + } + } + if let Some(image_user) = &docker_config.config.image_user { + return Ok(image_user.to_string()); + } + + Err(DevContainerError::DevContainerParseFailed) +} + +#[cfg(test)] +mod test { + use std::{ + collections::HashMap, + ffi::OsStr, + path::PathBuf, + process::{ExitStatus, Output}, + sync::{Arc, Mutex}, + }; + + use async_trait::async_trait; + use fs::{FakeFs, Fs}; + use gpui::{AppContext, TestAppContext}; + use http_client::{AsyncBody, FakeHttpClient, HttpClient}; + use project::{ + ProjectEnvironment, + worktree_store::{WorktreeIdCounter, WorktreeStore}, + }; + use serde_json_lenient::Value; + use util::{command::Command, paths::SanitizedPath}; + + use crate::{ + DevContainerConfig, DevContainerContext, + command_json::CommandRunner, + devcontainer_api::DevContainerError, + devcontainer_json::MountDefinition, + devcontainer_manifest::{ + ConfigStatus, DevContainerManifest, DockerBuildResources, DockerComposeResources, + DockerInspect, extract_feature_id, find_primary_service, get_remote_user_from_config, + }, + docker::{ + DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild, + DockerComposeVolume, DockerConfigLabels, DockerInspectConfig, DockerInspectMount, + DockerPs, + }, + oci::TokenResponse, + }; + const TEST_PROJECT_PATH: &str = "/path/to/local/project"; + + async fn build_tarball(content: Vec<(&str, &str)>) -> Vec { + let buffer = futures::io::Cursor::new(Vec::new()); + let mut builder = async_tar::Builder::new(buffer); + for (file_name, content) in content { + if content.is_empty() { + let mut header = async_tar::Header::new_gnu(); + header.set_size(0); + header.set_mode(0o755); + header.set_entry_type(async_tar::EntryType::Directory); + header.set_cksum(); + builder + .append_data(&mut header, file_name, &[] as &[u8]) + .await + .unwrap(); + } else { + let data = content.as_bytes(); + let mut header = async_tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o755); + header.set_entry_type(async_tar::EntryType::Regular); + header.set_cksum(); + builder + .append_data(&mut header, file_name, data) + .await + .unwrap(); + } + } + let buffer = builder.into_inner().await.unwrap(); + buffer.into_inner() + } + + fn test_project_filename() -> String { + PathBuf::from(TEST_PROJECT_PATH) + .file_name() + .expect("is valid") + .display() + .to_string() + } + + async fn init_devcontainer_config( + fs: &Arc, + devcontainer_contents: &str, + ) -> DevContainerConfig { + fs.insert_tree( + format!("{TEST_PROJECT_PATH}/.devcontainer"), + serde_json::json!({"devcontainer.json": devcontainer_contents}), + ) + .await; + + DevContainerConfig::default_config() + } + + struct TestDependencies { + fs: Arc, + _http_client: Arc, + docker: Arc, + command_runner: Arc, + } + + async fn init_default_devcontainer_manifest( + cx: &mut TestAppContext, + devcontainer_contents: &str, + ) -> Result<(TestDependencies, DevContainerManifest), DevContainerError> { + let fs = FakeFs::new(cx.executor()); + let http_client = fake_http_client(); + let command_runner = Arc::new(TestCommandRunner::new()); + let docker = Arc::new(FakeDocker::new()); + let environment = HashMap::new(); + + init_devcontainer_manifest( + cx, + fs, + http_client, + docker, + command_runner, + environment, + devcontainer_contents, + ) + .await + } + + async fn init_devcontainer_manifest( + cx: &mut TestAppContext, + fs: Arc, + http_client: Arc, + docker_client: Arc, + command_runner: Arc, + environment: HashMap, + devcontainer_contents: &str, + ) -> Result<(TestDependencies, DevContainerManifest), DevContainerError> { + let local_config = init_devcontainer_config(&fs, devcontainer_contents).await; + let project_path = SanitizedPath::new_arc(&PathBuf::from(TEST_PROJECT_PATH)); + let worktree_store = + cx.new(|_cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::default())); + let project_environment = + cx.new(|cx| ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)); + + let context = DevContainerContext { + project_directory: SanitizedPath::cast_arc(project_path), + use_podman: false, + fs: fs.clone(), + http_client: http_client.clone(), + environment: project_environment.downgrade(), + }; + + let test_dependencies = TestDependencies { + fs: fs.clone(), + _http_client: http_client.clone(), + docker: docker_client.clone(), + command_runner: command_runner.clone(), + }; + let manifest = DevContainerManifest::new( + &context, + environment, + docker_client, + command_runner, + local_config, + &PathBuf::from(TEST_PROJECT_PATH), + ) + .await?; + + Ok((test_dependencies, manifest)) + } + + #[gpui::test] + async fn should_get_remote_user_from_devcontainer_if_available(cx: &mut TestAppContext) { + let (_, devcontainer_manifest) = init_default_devcontainer_manifest( + cx, + r#" +// These are some external comments. serde_lenient should handle them +{ + // These are some internal comments + "image": "image", + "remoteUser": "root", +} + "#, + ) + .await + .unwrap(); + + let mut metadata = HashMap::new(); + metadata.insert( + "remoteUser".to_string(), + serde_json_lenient::Value::String("vsCode".to_string()), + ); + let given_docker_config = DockerInspect { + id: "docker_id".to_string(), + config: DockerInspectConfig { + labels: DockerConfigLabels { + metadata: Some(vec![metadata]), + }, + image_user: None, + env: Vec::new(), + }, + mounts: None, + state: None, + }; + + let remote_user = + get_remote_user_from_config(&given_docker_config, &devcontainer_manifest).unwrap(); + + assert_eq!(remote_user, "root".to_string()) + } + + #[gpui::test] + async fn should_get_remote_user_from_docker_config(cx: &mut TestAppContext) { + let (_, devcontainer_manifest) = + init_default_devcontainer_manifest(cx, "{}").await.unwrap(); + let mut metadata = HashMap::new(); + metadata.insert( + "remoteUser".to_string(), + serde_json_lenient::Value::String("vsCode".to_string()), + ); + let given_docker_config = DockerInspect { + id: "docker_id".to_string(), + config: DockerInspectConfig { + labels: DockerConfigLabels { + metadata: Some(vec![metadata]), + }, + image_user: None, + env: Vec::new(), + }, + mounts: None, + state: None, + }; + + let remote_user = get_remote_user_from_config(&given_docker_config, &devcontainer_manifest); + + assert!(remote_user.is_ok()); + let remote_user = remote_user.expect("ok"); + assert_eq!(&remote_user, "vsCode") + } + + #[test] + fn should_extract_feature_id_from_references() { + assert_eq!( + extract_feature_id("ghcr.io/devcontainers/features/aws-cli:1"), + "aws-cli" + ); + assert_eq!( + extract_feature_id("ghcr.io/devcontainers/features/go"), + "go" + ); + assert_eq!(extract_feature_id("ghcr.io/user/repo/node:18.0.0"), "node"); + assert_eq!(extract_feature_id("./myFeature"), "myFeature"); + assert_eq!( + extract_feature_id("ghcr.io/devcontainers/features/rust@sha256:abc123"), + "rust" + ); + } + + #[gpui::test] + async fn should_create_correct_docker_run_command(cx: &mut TestAppContext) { + let mut metadata = HashMap::new(); + metadata.insert( + "remoteUser".to_string(), + serde_json_lenient::Value::String("vsCode".to_string()), + ); + + let (_, devcontainer_manifest) = + init_default_devcontainer_manifest(cx, "{}").await.unwrap(); + let build_resources = DockerBuildResources { + image: DockerInspect { + id: "mcr.microsoft.com/devcontainers/base:ubuntu".to_string(), + config: DockerInspectConfig { + labels: DockerConfigLabels { metadata: None }, + image_user: None, + env: Vec::new(), + }, + mounts: None, + state: None, + }, + additional_mounts: vec![], + privileged: false, + entrypoint_script: "echo Container started\n trap \"exit 0\" 15\n exec \"$@\"\n while sleep 1 & wait $!; do :; done".to_string(), + }; + let docker_run_command = devcontainer_manifest.create_docker_run_command(build_resources); + + assert!(docker_run_command.is_ok()); + let docker_run_command = docker_run_command.expect("ok"); + + assert_eq!(docker_run_command.get_program(), "docker"); + let expected_config_file_label = PathBuf::from(TEST_PROJECT_PATH) + .join(".devcontainer") + .join("devcontainer.json"); + let expected_config_file_label = expected_config_file_label.display(); + assert_eq!( + docker_run_command.get_args().collect::>(), + vec![ + OsStr::new("run"), + OsStr::new("--sig-proxy=false"), + OsStr::new("-d"), + OsStr::new("--mount"), + OsStr::new( + "type=bind,source=/path/to/local/project,target=/workspaces/project,consistency=cached" + ), + OsStr::new("-l"), + OsStr::new("devcontainer.local_folder=/path/to/local/project"), + OsStr::new("-l"), + OsStr::new(&format!( + "devcontainer.config_file={expected_config_file_label}" + )), + OsStr::new("--entrypoint"), + OsStr::new("/bin/sh"), + OsStr::new("mcr.microsoft.com/devcontainers/base:ubuntu"), + OsStr::new("-c"), + OsStr::new( + " + echo Container started + trap \"exit 0\" 15 + exec \"$@\" + while sleep 1 & wait $!; do :; done + " + .trim() + ), + OsStr::new("-"), + ] + ) + } + + #[gpui::test] + async fn should_find_primary_service_in_docker_compose(cx: &mut TestAppContext) { + // State where service not defined in dev container + let (_, given_dev_container) = init_default_devcontainer_manifest(cx, "{}").await.unwrap(); + let given_docker_compose_config = DockerComposeResources { + config: DockerComposeConfig { + name: Some("devcontainers".to_string()), + services: HashMap::new(), + ..Default::default() + }, + ..Default::default() + }; + + let bad_result = find_primary_service(&given_docker_compose_config, &given_dev_container); + + assert!(bad_result.is_err()); + + // State where service defined in devcontainer, not found in DockerCompose config + let (_, given_dev_container) = + init_default_devcontainer_manifest(cx, r#"{"service": "not_found_service"}"#) + .await + .unwrap(); + let given_docker_compose_config = DockerComposeResources { + config: DockerComposeConfig { + name: Some("devcontainers".to_string()), + services: HashMap::new(), + ..Default::default() + }, + ..Default::default() + }; + + let bad_result = find_primary_service(&given_docker_compose_config, &given_dev_container); + + assert!(bad_result.is_err()); + // State where service defined in devcontainer and in DockerCompose config + + let (_, given_dev_container) = + init_default_devcontainer_manifest(cx, r#"{"service": "found_service"}"#) + .await + .unwrap(); + let given_docker_compose_config = DockerComposeResources { + config: DockerComposeConfig { + name: Some("devcontainers".to_string()), + services: HashMap::from([( + "found_service".to_string(), + DockerComposeService { + ..Default::default() + }, + )]), + ..Default::default() + }, + ..Default::default() + }; + + let (service_name, _) = + find_primary_service(&given_docker_compose_config, &given_dev_container).unwrap(); + + assert_eq!(service_name, "found_service".to_string()); + } + + #[gpui::test] + async fn test_nonremote_variable_replacement_with_default_mount(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + let given_devcontainer_contents = r#" +// These are some external comments. serde_lenient should handle them +{ + // These are some internal comments + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "name": "myDevContainer-${devcontainerId}", + "remoteUser": "root", + "remoteEnv": { + "DEVCONTAINER_ID": "${devcontainerId}", + "MYVAR2": "myvarothervalue", + "REMOTE_WORKSPACE_FOLDER_BASENAME": "${containerWorkspaceFolderBasename}", + "LOCAL_WORKSPACE_FOLDER_BASENAME": "${localWorkspaceFolderBasename}", + "REMOTE_WORKSPACE_FOLDER": "${containerWorkspaceFolder}", + "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}", + "LOCAL_ENV_VAR_1": "${localEnv:local_env_1}", + "LOCAL_ENV_VAR_2": "${localEnv:my_other_env}" + + } +} + "#; + let (_, mut devcontainer_manifest) = init_devcontainer_manifest( + cx, + fs, + fake_http_client(), + Arc::new(FakeDocker::new()), + Arc::new(TestCommandRunner::new()), + HashMap::from([ + ("local_env_1".to_string(), "local_env_value1".to_string()), + ("my_other_env".to_string(), "THISVALUEHERE".to_string()), + ]), + given_devcontainer_contents, + ) + .await + .unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let ConfigStatus::VariableParsed(variable_replaced_devcontainer) = + &devcontainer_manifest.config + else { + panic!("Config not parsed"); + }; + + // ${devcontainerId} + let devcontainer_id = devcontainer_manifest.devcontainer_id(); + assert_eq!( + variable_replaced_devcontainer.name, + Some(format!("myDevContainer-{devcontainer_id}")) + ); + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("DEVCONTAINER_ID")), + Some(&devcontainer_id) + ); + + // ${containerWorkspaceFolderBasename} + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("REMOTE_WORKSPACE_FOLDER_BASENAME")), + Some(&test_project_filename()) + ); + + // ${localWorkspaceFolderBasename} + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("LOCAL_WORKSPACE_FOLDER_BASENAME")), + Some(&test_project_filename()) + ); + + // ${containerWorkspaceFolder} + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("REMOTE_WORKSPACE_FOLDER")), + Some(&format!("/workspaces/{}", test_project_filename())) + ); + + // ${localWorkspaceFolder} + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("LOCAL_WORKSPACE_FOLDER")), + Some(&TEST_PROJECT_PATH.to_string()) + ); + + // ${localEnv:VARIABLE_NAME} + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("LOCAL_ENV_VAR_1")), + Some(&"local_env_value1".to_string()) + ); + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("LOCAL_ENV_VAR_2")), + Some(&"THISVALUEHERE".to_string()) + ); + } + + #[gpui::test] + async fn test_nonremote_variable_replacement_with_explicit_mount(cx: &mut TestAppContext) { + let given_devcontainer_contents = r#" + // These are some external comments. serde_lenient should handle them + { + // These are some internal comments + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "name": "myDevContainer-${devcontainerId}", + "remoteUser": "root", + "remoteEnv": { + "DEVCONTAINER_ID": "${devcontainerId}", + "MYVAR2": "myvarothervalue", + "REMOTE_WORKSPACE_FOLDER_BASENAME": "${containerWorkspaceFolderBasename}", + "LOCAL_WORKSPACE_FOLDER_BASENAME": "${localWorkspaceFolderBasename}", + "REMOTE_WORKSPACE_FOLDER": "${containerWorkspaceFolder}", + "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" + + }, + "workspaceMount": "source=/local/folder,target=/workspace/subfolder,type=bind,consistency=cached", + "workspaceFolder": "/workspace/customfolder" + } + "#; + + let (_, mut devcontainer_manifest) = + init_default_devcontainer_manifest(cx, given_devcontainer_contents) + .await + .unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let ConfigStatus::VariableParsed(variable_replaced_devcontainer) = + &devcontainer_manifest.config + else { + panic!("Config not parsed"); + }; + + // ${devcontainerId} + let devcontainer_id = devcontainer_manifest.devcontainer_id(); + assert_eq!( + variable_replaced_devcontainer.name, + Some(format!("myDevContainer-{devcontainer_id}")) + ); + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("DEVCONTAINER_ID")), + Some(&devcontainer_id) + ); + + // ${containerWorkspaceFolderBasename} + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("REMOTE_WORKSPACE_FOLDER_BASENAME")), + Some(&"customfolder".to_string()) + ); + + // ${localWorkspaceFolderBasename} + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("LOCAL_WORKSPACE_FOLDER_BASENAME")), + Some(&"project".to_string()) + ); + + // ${containerWorkspaceFolder} + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("REMOTE_WORKSPACE_FOLDER")), + Some(&"/workspace/customfolder".to_string()) + ); + + // ${localWorkspaceFolder} + assert_eq!( + variable_replaced_devcontainer + .remote_env + .as_ref() + .and_then(|env| env.get("LOCAL_WORKSPACE_FOLDER")), + Some(&TEST_PROJECT_PATH.to_string()) + ); + } + + // updateRemoteUserUID is treated as false in Windows, so this test will fail + // It is covered by test_spawns_devcontainer_with_dockerfile_and_no_update_uid + #[cfg(not(target_os = "windows"))] + #[gpui::test] + async fn test_spawns_devcontainer_with_dockerfile_and_features(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + env_logger::try_init().ok(); + let given_devcontainer_contents = r#" + /*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + { + "name": "cli-${devcontainerId}", + // "image": "mcr.microsoft.com/devcontainers/typescript-node:16-bullseye", + "build": { + "dockerfile": "Dockerfile", + "args": { + "VARIANT": "18-bookworm", + "FOO": "bar", + }, + }, + "workspaceMount": "source=${localWorkspaceFolder},target=${containerWorkspaceFolder},type=bind,consistency=cached", + "workspaceFolder": "/workspace2", + "mounts": [ + // Keep command history across instances + "source=dev-containers-cli-bashhistory,target=/home/node/commandhistory", + ], + + "forwardPorts": [ + 8082, + 8083, + ], + "appPort": "8084", + + "containerEnv": { + "VARIABLE_VALUE": "value", + }, + + "initializeCommand": "touch IAM.md", + + "onCreateCommand": "echo 'onCreateCommand' >> ON_CREATE_COMMAND.md", + + "updateContentCommand": "echo 'updateContentCommand' >> UPDATE_CONTENT_COMMAND.md", + + "postCreateCommand": { + "yarn": "yarn install", + "debug": "echo 'postStartCommand' >> POST_START_COMMAND.md", + }, + + "postStartCommand": "echo 'postStartCommand' >> POST_START_COMMAND.md", + + "postAttachCommand": "echo 'postAttachCommand' >> POST_ATTACH_COMMAND.md", + + "remoteUser": "node", + + "remoteEnv": { + "PATH": "${containerEnv:PATH}:/some/other/path", + "OTHER_ENV": "other_env_value" + }, + + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "moby": false, + }, + "ghcr.io/devcontainers/features/go:1": {}, + }, + + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "GitHub.vscode-pull-request-github", + ], + }, + "zed": { + "extensions": ["vue", "ruby"], + }, + "codespaces": { + "repositories": { + "devcontainers/features": { + "permissions": { + "contents": "write", + "workflows": "write", + }, + }, + }, + }, + }, + } + "#; + + let (test_dependencies, mut devcontainer_manifest) = + init_default_devcontainer_manifest(cx, given_devcontainer_contents) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"), + r#" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +ARG VARIANT="16-bullseye" +FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} + +RUN mkdir -p /workspaces && chown node:node /workspaces + +ARG USERNAME=node +USER $USERNAME + +# Save command line history +RUN echo "export HISTFILE=/home/$USERNAME/commandhistory/.bash_history" >> "/home/$USERNAME/.bashrc" \ +&& echo "export PROMPT_COMMAND='history -a'" >> "/home/$USERNAME/.bashrc" \ +&& mkdir -p /home/$USERNAME/commandhistory \ +&& touch /home/$USERNAME/commandhistory/.bash_history \ +&& chown -R $USERNAME /home/$USERNAME/commandhistory + "#.trim().to_string(), + ) + .await + .unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap(); + + assert_eq!( + devcontainer_up.extension_ids, + vec!["vue".to_string(), "ruby".to_string()] + ); + + let files = test_dependencies.fs.files(); + let feature_dockerfile = files + .iter() + .find(|f| { + f.file_name() + .is_some_and(|s| s.display().to_string() == "Dockerfile.extended") + }) + .expect("to be found"); + let feature_dockerfile = test_dependencies.fs.load(feature_dockerfile).await.unwrap(); + assert_eq!( + &feature_dockerfile, + r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder + +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +ARG VARIANT="16-bullseye" +FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} AS dev_container_auto_added_stage_label + +RUN mkdir -p /workspaces && chown node:node /workspaces + +ARG USERNAME=node +USER $USERNAME + +# Save command line history +RUN echo "export HISTFILE=/home/$USERNAME/commandhistory/.bash_history" >> "/home/$USERNAME/.bashrc" \ +&& echo "export PROMPT_COMMAND='history -a'" >> "/home/$USERNAME/.bashrc" \ +&& mkdir -p /home/$USERNAME/commandhistory \ +&& touch /home/$USERNAME/commandhistory/.bash_history \ +&& chown -R $USERNAME /home/$USERNAME/commandhistory + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize +USER root +COPY --from=dev_containers_feature_content_source ./devcontainer-features.builtin.env /tmp/build-features/ +RUN chmod -R 0755 /tmp/build-features/ + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage + +USER root + +RUN mkdir -p /tmp/dev-container-features +COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ /tmp/dev-container-features + +RUN \ +echo "_CONTAINER_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'root' || grep -E '^root|^[^:]*:[^:]*:root:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && \ +echo "_REMOTE_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'node' || grep -E '^node|^[^:]*:[^:]*:node:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env + + +RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./docker-in-docker_0,target=/tmp/build-features-src/docker-in-docker_0 \ +cp -ar /tmp/build-features-src/docker-in-docker_0 /tmp/dev-container-features \ +&& chmod -R 0755 /tmp/dev-container-features/docker-in-docker_0 \ +&& cd /tmp/dev-container-features/docker-in-docker_0 \ +&& chmod +x ./devcontainer-features-install.sh \ +&& ./devcontainer-features-install.sh \ +&& rm -rf /tmp/dev-container-features/docker-in-docker_0 + +RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./go_1,target=/tmp/build-features-src/go_1 \ +cp -ar /tmp/build-features-src/go_1 /tmp/dev-container-features \ +&& chmod -R 0755 /tmp/dev-container-features/go_1 \ +&& cd /tmp/dev-container-features/go_1 \ +&& chmod +x ./devcontainer-features-install.sh \ +&& ./devcontainer-features-install.sh \ +&& rm -rf /tmp/dev-container-features/go_1 + + +ARG _DEV_CONTAINERS_IMAGE_USER=root +USER $_DEV_CONTAINERS_IMAGE_USER +"# + ); + + let uid_dockerfile = files + .iter() + .find(|f| { + f.file_name() + .is_some_and(|s| s.display().to_string() == "updateUID.Dockerfile") + }) + .expect("to be found"); + let uid_dockerfile = test_dependencies.fs.load(uid_dockerfile).await.unwrap(); + + assert_eq!( + &uid_dockerfile, + r#"ARG BASE_IMAGE +FROM $BASE_IMAGE + +USER root + +ARG REMOTE_USER +ARG NEW_UID +ARG NEW_GID +SHELL ["/bin/sh", "-c"] +RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/OLD_UID=\1;OLD_GID=\2;HOME_FOLDER=\3/p" /etc/passwd); \ + eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_UID}:.*/EXISTING_USER=\1/p" /etc/passwd); \ + eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_GID}:.*/EXISTING_GROUP=\1/p" /etc/group); \ + if [ -z "$OLD_UID" ]; then \ + echo "Remote user not found in /etc/passwd ($REMOTE_USER)."; \ + elif [ "$OLD_UID" = "$NEW_UID" -a "$OLD_GID" = "$NEW_GID" ]; then \ + echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \ + elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \ + echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \ + else \ + if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \ + FREE_GID=65532; \ + while grep -q ":[^:]*:${FREE_GID}:" /etc/group; do FREE_GID=$((FREE_GID - 1)); done; \ + echo "Reassigning group $EXISTING_GROUP from GID $NEW_GID to $FREE_GID."; \ + sed -i -e "s/\(${EXISTING_GROUP}:[^:]*:\)${NEW_GID}:/\1${FREE_GID}:/" /etc/group; \ + fi; \ + echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \ + sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \ + if [ "$OLD_GID" != "$NEW_GID" ]; then \ + sed -i -e "s/\([^:]*:[^:]*:\)${OLD_GID}:/\1${NEW_GID}:/" /etc/group; \ + fi; \ + chown -R $NEW_UID:$NEW_GID $HOME_FOLDER; \ + fi; + +ARG IMAGE_USER +USER $IMAGE_USER + +# Ensure that /etc/profile does not clobber the existing path +RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true + +ENV DOCKER_BUILDKIT=1 + +ENV GOPATH=/go +ENV GOROOT=/usr/local/go +ENV PATH=/usr/local/go/bin:/go/bin:${PATH} +ENV VARIABLE_VALUE=value +"# + ); + + let golang_install_wrapper = files + .iter() + .find(|f| { + f.file_name() + .is_some_and(|s| s.display().to_string() == "devcontainer-features-install.sh") + && f.to_str().is_some_and(|s| s.contains("/go_")) + }) + .expect("to be found"); + let golang_install_wrapper = test_dependencies + .fs + .load(golang_install_wrapper) + .await + .unwrap(); + assert_eq!( + &golang_install_wrapper, + r#"#!/bin/sh +set -e + +on_exit () { + [ $? -eq 0 ] && exit + echo 'ERROR: Feature "go" (ghcr.io/devcontainers/features/go:1) failed to install!' +} + +trap on_exit EXIT + +echo =========================================================================== +echo 'Feature : go' +echo 'Id : ghcr.io/devcontainers/features/go:1' +echo 'Options :' +echo ' GOLANGCILINTVERSION=latest + VERSION=latest' +echo =========================================================================== + +set -a +. ../devcontainer-features.builtin.env +. ./devcontainer-features.env +set +a + +chmod +x ./install.sh +./install.sh +"# + ); + + let docker_commands = test_dependencies + .command_runner + .commands_by_program("docker"); + + let docker_run_command = docker_commands + .iter() + .find(|c| c.args.get(0).is_some_and(|a| a == "run")) + .expect("found"); + + assert_eq!( + docker_run_command.args, + vec![ + "run".to_string(), + "--privileged".to_string(), + "--sig-proxy=false".to_string(), + "-d".to_string(), + "--mount".to_string(), + "type=bind,source=/path/to/local/project,target=/workspace2,consistency=cached".to_string(), + "--mount".to_string(), + "type=volume,source=dev-containers-cli-bashhistory,target=/home/node/commandhistory,consistency=cached".to_string(), + "--mount".to_string(), + "type=volume,source=dind-var-lib-docker-42dad4b4ca7b8ced,target=/var/lib/docker,consistency=cached".to_string(), + "-l".to_string(), + "devcontainer.local_folder=/path/to/local/project".to_string(), + "-l".to_string(), + "devcontainer.config_file=/path/to/local/project/.devcontainer/devcontainer.json".to_string(), + "-l".to_string(), + "devcontainer.metadata=[{\"remoteUser\":\"node\"}]".to_string(), + "-p".to_string(), + "8082:8082".to_string(), + "-p".to_string(), + "8083:8083".to_string(), + "-p".to_string(), + "8084:8084".to_string(), + "--entrypoint".to_string(), + "/bin/sh".to_string(), + "sha256:610e6cfca95280188b021774f8cf69dd6f49bdb6eebc34c5ee2010f4d51cc105".to_string(), + "-c".to_string(), + "echo Container started\ntrap \"exit 0\" 15\n/usr/local/share/docker-init.sh\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done".to_string(), + "-".to_string() + ] + ); + + let docker_exec_commands = test_dependencies + .docker + .exec_commands_recorded + .lock() + .unwrap(); + + assert!(docker_exec_commands.iter().all(|exec| { + exec.env + == HashMap::from([ + ("OTHER_ENV".to_string(), "other_env_value".to_string()), + ( + "PATH".to_string(), + "/initial/path:/some/other/path".to_string(), + ), + ]) + })) + } + + // updateRemoteUserUID is treated as false in Windows, so this test will fail + // It is covered by test_spawns_devcontainer_with_docker_compose_and_no_update_uid + #[cfg(not(target_os = "windows"))] + #[gpui::test] + async fn test_spawns_devcontainer_with_docker_compose(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + env_logger::try_init().ok(); + let given_devcontainer_contents = r#" + // For format details, see https://aka.ms/devcontainer.json. For config options, see the + // README at: https://github.com/devcontainers/templates/tree/main/src/rust-postgres + { + "features": { + "ghcr.io/devcontainers/features/aws-cli:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + }, + "name": "Rust and PostgreSQL", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [ + 8083, + "db:5432", + "db:1234", + ], + "appPort": "8084", + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "rustc --version", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" + } + "#; + let (test_dependencies, mut devcontainer_manifest) = + init_default_devcontainer_manifest(cx, given_devcontainer_contents) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"), + r#" +version: '3.8' + +volumes: + postgres-data: + +services: + app: + build: + context: . + dockerfile: Dockerfile + env_file: + # Ensure that the variables in .env match the same variables in devcontainer.json + - .env + + volumes: + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + db: + image: postgres:14.1 + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + env_file: + # Ensure that the variables in .env match the same variables in devcontainer.json + - .env + + # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + "#.trim().to_string(), + ) + .await + .unwrap(); + + test_dependencies.fs.atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"), + r#" +FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm + +# Include lld linker to improve build times either by using environment variable +# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml). +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install clang lld \ + && apt-get autoremove -y && apt-get clean -y + "#.trim().to_string()).await.unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap(); + + let files = test_dependencies.fs.files(); + let feature_dockerfile = files + .iter() + .find(|f| { + f.file_name() + .is_some_and(|s| s.display().to_string() == "Dockerfile.extended") + }) + .expect("to be found"); + let feature_dockerfile = test_dependencies.fs.load(feature_dockerfile).await.unwrap(); + assert_eq!( + &feature_dockerfile, + r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder + +FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm AS dev_container_auto_added_stage_label + +# Include lld linker to improve build times either by using environment variable +# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml). +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install clang lld \ + && apt-get autoremove -y && apt-get clean -y + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize +USER root +COPY --from=dev_containers_feature_content_source ./devcontainer-features.builtin.env /tmp/build-features/ +RUN chmod -R 0755 /tmp/build-features/ + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage + +USER root + +RUN mkdir -p /tmp/dev-container-features +COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ /tmp/dev-container-features + +RUN \ +echo "_CONTAINER_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'root' || grep -E '^root|^[^:]*:[^:]*:root:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && \ +echo "_REMOTE_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'vscode' || grep -E '^vscode|^[^:]*:[^:]*:vscode:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env + + +RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./aws-cli_0,target=/tmp/build-features-src/aws-cli_0 \ +cp -ar /tmp/build-features-src/aws-cli_0 /tmp/dev-container-features \ +&& chmod -R 0755 /tmp/dev-container-features/aws-cli_0 \ +&& cd /tmp/dev-container-features/aws-cli_0 \ +&& chmod +x ./devcontainer-features-install.sh \ +&& ./devcontainer-features-install.sh \ +&& rm -rf /tmp/dev-container-features/aws-cli_0 + +RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./docker-in-docker_1,target=/tmp/build-features-src/docker-in-docker_1 \ +cp -ar /tmp/build-features-src/docker-in-docker_1 /tmp/dev-container-features \ +&& chmod -R 0755 /tmp/dev-container-features/docker-in-docker_1 \ +&& cd /tmp/dev-container-features/docker-in-docker_1 \ +&& chmod +x ./devcontainer-features-install.sh \ +&& ./devcontainer-features-install.sh \ +&& rm -rf /tmp/dev-container-features/docker-in-docker_1 + + +ARG _DEV_CONTAINERS_IMAGE_USER=root +USER $_DEV_CONTAINERS_IMAGE_USER +"# + ); + + let uid_dockerfile = files + .iter() + .find(|f| { + f.file_name() + .is_some_and(|s| s.display().to_string() == "updateUID.Dockerfile") + }) + .expect("to be found"); + let uid_dockerfile = test_dependencies.fs.load(uid_dockerfile).await.unwrap(); + + assert_eq!( + &uid_dockerfile, + r#"ARG BASE_IMAGE +FROM $BASE_IMAGE + +USER root + +ARG REMOTE_USER +ARG NEW_UID +ARG NEW_GID +SHELL ["/bin/sh", "-c"] +RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/OLD_UID=\1;OLD_GID=\2;HOME_FOLDER=\3/p" /etc/passwd); \ + eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_UID}:.*/EXISTING_USER=\1/p" /etc/passwd); \ + eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_GID}:.*/EXISTING_GROUP=\1/p" /etc/group); \ + if [ -z "$OLD_UID" ]; then \ + echo "Remote user not found in /etc/passwd ($REMOTE_USER)."; \ + elif [ "$OLD_UID" = "$NEW_UID" -a "$OLD_GID" = "$NEW_GID" ]; then \ + echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \ + elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \ + echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \ + else \ + if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \ + FREE_GID=65532; \ + while grep -q ":[^:]*:${FREE_GID}:" /etc/group; do FREE_GID=$((FREE_GID - 1)); done; \ + echo "Reassigning group $EXISTING_GROUP from GID $NEW_GID to $FREE_GID."; \ + sed -i -e "s/\(${EXISTING_GROUP}:[^:]*:\)${NEW_GID}:/\1${FREE_GID}:/" /etc/group; \ + fi; \ + echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \ + sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \ + if [ "$OLD_GID" != "$NEW_GID" ]; then \ + sed -i -e "s/\([^:]*:[^:]*:\)${OLD_GID}:/\1${NEW_GID}:/" /etc/group; \ + fi; \ + chown -R $NEW_UID:$NEW_GID $HOME_FOLDER; \ + fi; + +ARG IMAGE_USER +USER $IMAGE_USER + +# Ensure that /etc/profile does not clobber the existing path +RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true + + +ENV DOCKER_BUILDKIT=1 +"# + ); + + let runtime_override = files + .iter() + .find(|f| { + f.file_name() + .is_some_and(|s| s.display().to_string() == "docker_compose_runtime.json") + }) + .expect("to be found"); + let runtime_override = test_dependencies.fs.load(runtime_override).await.unwrap(); + + let expected_runtime_override = DockerComposeConfig { + name: None, + services: HashMap::from([ + ( + "app".to_string(), + DockerComposeService { + entrypoint: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo Container started\ntrap \"exit 0\" 15\n/usr/local/share/docker-init.sh\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done".to_string(), + "-".to_string(), + ]), + cap_add: Some(vec!["SYS_PTRACE".to_string()]), + security_opt: Some(vec!["seccomp=unconfined".to_string()]), + privileged: Some(true), + labels: Some(vec![ + "devcontainer.metadata=[{\"remoteUser\":\"vscode\"}]".to_string(), + "devcontainer.local_folder=/path/to/local/project".to_string(), + "devcontainer.config_file=/path/to/local/project/.devcontainer/devcontainer.json".to_string() + ]), + volumes: vec![ + MountDefinition { + source: "dind-var-lib-docker-42dad4b4ca7b8ced".to_string(), + target: "/var/lib/docker".to_string(), + mount_type: Some("volume".to_string()) + } + ], + ..Default::default() + }, + ), + ( + "db".to_string(), + DockerComposeService { + ports: vec![ + "8083:8083".to_string(), + "5432:5432".to_string(), + "1234:1234".to_string(), + "8084:8084".to_string() + ], + ..Default::default() + }, + ), + ]), + volumes: HashMap::from([( + "dind-var-lib-docker-42dad4b4ca7b8ced".to_string(), + DockerComposeVolume { + name: "dind-var-lib-docker-42dad4b4ca7b8ced".to_string(), + }, + )]), + }; + + assert_eq!( + serde_json_lenient::from_str::(&runtime_override).unwrap(), + expected_runtime_override + ) + } + + #[gpui::test] + async fn test_spawns_devcontainer_with_docker_compose_and_no_update_uid( + cx: &mut TestAppContext, + ) { + cx.executor().allow_parking(); + env_logger::try_init().ok(); + let given_devcontainer_contents = r#" + // For format details, see https://aka.ms/devcontainer.json. For config options, see the + // README at: https://github.com/devcontainers/templates/tree/main/src/rust-postgres + { + "features": { + "ghcr.io/devcontainers/features/aws-cli:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + }, + "name": "Rust and PostgreSQL", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [ + 8083, + "db:5432", + "db:1234", + ], + "updateRemoteUserUID": false, + "appPort": "8084", + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "rustc --version", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" + } + "#; + let (test_dependencies, mut devcontainer_manifest) = + init_default_devcontainer_manifest(cx, given_devcontainer_contents) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"), + r#" +version: '3.8' + +volumes: +postgres-data: + +services: +app: + build: + context: . + dockerfile: Dockerfile + env_file: + # Ensure that the variables in .env match the same variables in devcontainer.json + - .env + + volumes: + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + +db: + image: postgres:14.1 + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + env_file: + # Ensure that the variables in .env match the same variables in devcontainer.json + - .env + + # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + "#.trim().to_string(), + ) + .await + .unwrap(); + + test_dependencies.fs.atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"), + r#" +FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm + +# Include lld linker to improve build times either by using environment variable +# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml). +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +&& apt-get -y install clang lld \ +&& apt-get autoremove -y && apt-get clean -y + "#.trim().to_string()).await.unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap(); + + let files = test_dependencies.fs.files(); + let feature_dockerfile = files + .iter() + .find(|f| { + f.file_name() + .is_some_and(|s| s.display().to_string() == "Dockerfile.extended") + }) + .expect("to be found"); + let feature_dockerfile = test_dependencies.fs.load(feature_dockerfile).await.unwrap(); + assert_eq!( + &feature_dockerfile, + r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder + +FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm AS dev_container_auto_added_stage_label + +# Include lld linker to improve build times either by using environment variable +# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml). +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +&& apt-get -y install clang lld \ +&& apt-get autoremove -y && apt-get clean -y + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize +USER root +COPY --from=dev_containers_feature_content_source ./devcontainer-features.builtin.env /tmp/build-features/ +RUN chmod -R 0755 /tmp/build-features/ + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage + +USER root + +RUN mkdir -p /tmp/dev-container-features +COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ /tmp/dev-container-features + +RUN \ +echo "_CONTAINER_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'root' || grep -E '^root|^[^:]*:[^:]*:root:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && \ +echo "_REMOTE_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'vscode' || grep -E '^vscode|^[^:]*:[^:]*:vscode:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env + + +RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./aws-cli_0,target=/tmp/build-features-src/aws-cli_0 \ +cp -ar /tmp/build-features-src/aws-cli_0 /tmp/dev-container-features \ +&& chmod -R 0755 /tmp/dev-container-features/aws-cli_0 \ +&& cd /tmp/dev-container-features/aws-cli_0 \ +&& chmod +x ./devcontainer-features-install.sh \ +&& ./devcontainer-features-install.sh \ +&& rm -rf /tmp/dev-container-features/aws-cli_0 + +RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./docker-in-docker_1,target=/tmp/build-features-src/docker-in-docker_1 \ +cp -ar /tmp/build-features-src/docker-in-docker_1 /tmp/dev-container-features \ +&& chmod -R 0755 /tmp/dev-container-features/docker-in-docker_1 \ +&& cd /tmp/dev-container-features/docker-in-docker_1 \ +&& chmod +x ./devcontainer-features-install.sh \ +&& ./devcontainer-features-install.sh \ +&& rm -rf /tmp/dev-container-features/docker-in-docker_1 + + +ARG _DEV_CONTAINERS_IMAGE_USER=root +USER $_DEV_CONTAINERS_IMAGE_USER + +# Ensure that /etc/profile does not clobber the existing path +RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true + + +ENV DOCKER_BUILDKIT=1 +"# + ); + } + + #[cfg(not(target_os = "windows"))] + #[gpui::test] + async fn test_spawns_devcontainer_with_docker_compose_and_podman(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + env_logger::try_init().ok(); + let given_devcontainer_contents = r#" + // For format details, see https://aka.ms/devcontainer.json. For config options, see the + // README at: https://github.com/devcontainers/templates/tree/main/src/rust-postgres + { + "features": { + "ghcr.io/devcontainers/features/aws-cli:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + }, + "name": "Rust and PostgreSQL", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [5432], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "rustc --version", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" + } + "#; + let mut fake_docker = FakeDocker::new(); + fake_docker.set_podman(true); + let (test_dependencies, mut devcontainer_manifest) = init_devcontainer_manifest( + cx, + FakeFs::new(cx.executor()), + fake_http_client(), + Arc::new(fake_docker), + Arc::new(TestCommandRunner::new()), + HashMap::new(), + given_devcontainer_contents, + ) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"), + r#" +version: '3.8' + +volumes: +postgres-data: + +services: +app: +build: + context: . + dockerfile: Dockerfile +env_file: + # Ensure that the variables in .env match the same variables in devcontainer.json + - .env + +volumes: + - ../..:/workspaces:cached + +# Overrides default command so things don't shut down after the process ends. +command: sleep infinity + +# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. +network_mode: service:db + +# Use "forwardPorts" in **devcontainer.json** to forward an app port locally. +# (Adding the "ports" property to this file will not forward from a Codespace.) + +db: +image: postgres:14.1 +restart: unless-stopped +volumes: + - postgres-data:/var/lib/postgresql/data +env_file: + # Ensure that the variables in .env match the same variables in devcontainer.json + - .env + +# Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. +# (Adding the "ports" property to this file will not forward from a Codespace.) + "#.trim().to_string(), + ) + .await + .unwrap(); + + test_dependencies.fs.atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"), + r#" +FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm + +# Include lld linker to improve build times either by using environment variable +# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml). +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +&& apt-get -y install clang lld \ +&& apt-get autoremove -y && apt-get clean -y + "#.trim().to_string()).await.unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap(); + + let files = test_dependencies.fs.files(); + + let feature_dockerfile = files + .iter() + .find(|f| { + f.file_name() + .is_some_and(|s| s.display().to_string() == "Dockerfile.extended") + }) + .expect("to be found"); + let feature_dockerfile = test_dependencies.fs.load(feature_dockerfile).await.unwrap(); + assert_eq!( + &feature_dockerfile, + r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder + +FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm AS dev_container_auto_added_stage_label + +# Include lld linker to improve build times either by using environment variable +# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml). +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +&& apt-get -y install clang lld \ +&& apt-get autoremove -y && apt-get clean -y + +FROM dev_container_feature_content_temp as dev_containers_feature_content_source + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize +USER root +COPY --from=dev_containers_feature_content_source /tmp/build-features/devcontainer-features.builtin.env /tmp/build-features/ +RUN chmod -R 0755 /tmp/build-features/ + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage + +USER root + +RUN mkdir -p /tmp/dev-container-features +COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ /tmp/dev-container-features + +RUN \ +echo "_CONTAINER_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'root' || grep -E '^root|^[^:]*:[^:]*:root:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && \ +echo "_REMOTE_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'vscode' || grep -E '^vscode|^[^:]*:[^:]*:vscode:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env + + +COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/aws-cli_0 /tmp/dev-container-features/aws-cli_0 +RUN chmod -R 0755 /tmp/dev-container-features/aws-cli_0 \ +&& cd /tmp/dev-container-features/aws-cli_0 \ +&& chmod +x ./devcontainer-features-install.sh \ +&& ./devcontainer-features-install.sh + +COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/docker-in-docker_1 /tmp/dev-container-features/docker-in-docker_1 +RUN chmod -R 0755 /tmp/dev-container-features/docker-in-docker_1 \ +&& cd /tmp/dev-container-features/docker-in-docker_1 \ +&& chmod +x ./devcontainer-features-install.sh \ +&& ./devcontainer-features-install.sh + + +ARG _DEV_CONTAINERS_IMAGE_USER=root +USER $_DEV_CONTAINERS_IMAGE_USER +"# + ); + + let uid_dockerfile = files + .iter() + .find(|f| { + f.file_name() + .is_some_and(|s| s.display().to_string() == "updateUID.Dockerfile") + }) + .expect("to be found"); + let uid_dockerfile = test_dependencies.fs.load(uid_dockerfile).await.unwrap(); + + assert_eq!( + &uid_dockerfile, + r#"ARG BASE_IMAGE +FROM $BASE_IMAGE + +USER root + +ARG REMOTE_USER +ARG NEW_UID +ARG NEW_GID +SHELL ["/bin/sh", "-c"] +RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/OLD_UID=\1;OLD_GID=\2;HOME_FOLDER=\3/p" /etc/passwd); \ + eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_UID}:.*/EXISTING_USER=\1/p" /etc/passwd); \ + eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_GID}:.*/EXISTING_GROUP=\1/p" /etc/group); \ + if [ -z "$OLD_UID" ]; then \ + echo "Remote user not found in /etc/passwd ($REMOTE_USER)."; \ + elif [ "$OLD_UID" = "$NEW_UID" -a "$OLD_GID" = "$NEW_GID" ]; then \ + echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \ + elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \ + echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \ + else \ + if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \ + FREE_GID=65532; \ + while grep -q ":[^:]*:${FREE_GID}:" /etc/group; do FREE_GID=$((FREE_GID - 1)); done; \ + echo "Reassigning group $EXISTING_GROUP from GID $NEW_GID to $FREE_GID."; \ + sed -i -e "s/\(${EXISTING_GROUP}:[^:]*:\)${NEW_GID}:/\1${FREE_GID}:/" /etc/group; \ + fi; \ + echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \ + sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \ + if [ "$OLD_GID" != "$NEW_GID" ]; then \ + sed -i -e "s/\([^:]*:[^:]*:\)${OLD_GID}:/\1${NEW_GID}:/" /etc/group; \ + fi; \ + chown -R $NEW_UID:$NEW_GID $HOME_FOLDER; \ + fi; + +ARG IMAGE_USER +USER $IMAGE_USER + +# Ensure that /etc/profile does not clobber the existing path +RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true + + +ENV DOCKER_BUILDKIT=1 +"# + ); + } + + #[gpui::test] + async fn test_spawns_devcontainer_with_dockerfile_and_no_update_uid(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + env_logger::try_init().ok(); + let given_devcontainer_contents = r#" + /*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + { + "name": "cli-${devcontainerId}", + // "image": "mcr.microsoft.com/devcontainers/typescript-node:16-bullseye", + "build": { + "dockerfile": "Dockerfile", + "args": { + "VARIANT": "18-bookworm", + "FOO": "bar", + }, + }, + "workspaceMount": "source=${localWorkspaceFolder},target=${containerWorkspaceFolder},type=bind,consistency=cached", + "workspaceFolder": "/workspace2", + "mounts": [ + // Keep command history across instances + "source=dev-containers-cli-bashhistory,target=/home/node/commandhistory", + ], + + "forwardPorts": [ + 8082, + 8083, + ], + "appPort": "8084", + "updateRemoteUserUID": false, + + "containerEnv": { + "VARIABLE_VALUE": "value", + }, + + "initializeCommand": "touch IAM.md", + + "onCreateCommand": "echo 'onCreateCommand' >> ON_CREATE_COMMAND.md", + + "updateContentCommand": "echo 'updateContentCommand' >> UPDATE_CONTENT_COMMAND.md", + + "postCreateCommand": { + "yarn": "yarn install", + "debug": "echo 'postStartCommand' >> POST_START_COMMAND.md", + }, + + "postStartCommand": "echo 'postStartCommand' >> POST_START_COMMAND.md", + + "postAttachCommand": "echo 'postAttachCommand' >> POST_ATTACH_COMMAND.md", + + "remoteUser": "node", + + "remoteEnv": { + "PATH": "${containerEnv:PATH}:/some/other/path", + "OTHER_ENV": "other_env_value" + }, + + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "moby": false, + }, + "ghcr.io/devcontainers/features/go:1": {}, + }, + + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "GitHub.vscode-pull-request-github", + ], + }, + "zed": { + "extensions": ["vue", "ruby"], + }, + "codespaces": { + "repositories": { + "devcontainers/features": { + "permissions": { + "contents": "write", + "workflows": "write", + }, + }, + }, + }, + }, + } + "#; + + let (test_dependencies, mut devcontainer_manifest) = + init_default_devcontainer_manifest(cx, given_devcontainer_contents) + .await + .unwrap(); + + test_dependencies + .fs + .atomic_write( + PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"), + r#" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +ARG VARIANT="16-bullseye" +FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} + +RUN mkdir -p /workspaces && chown node:node /workspaces + +ARG USERNAME=node +USER $USERNAME + +# Save command line history +RUN echo "export HISTFILE=/home/$USERNAME/commandhistory/.bash_history" >> "/home/$USERNAME/.bashrc" \ +&& echo "export PROMPT_COMMAND='history -a'" >> "/home/$USERNAME/.bashrc" \ +&& mkdir -p /home/$USERNAME/commandhistory \ +&& touch /home/$USERNAME/commandhistory/.bash_history \ +&& chown -R $USERNAME /home/$USERNAME/commandhistory + "#.trim().to_string(), + ) + .await + .unwrap(); + + devcontainer_manifest.parse_nonremote_vars().unwrap(); + + let devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap(); + + assert_eq!( + devcontainer_up.extension_ids, + vec!["vue".to_string(), "ruby".to_string()] + ); + + let files = test_dependencies.fs.files(); + let feature_dockerfile = files + .iter() + .find(|f| { + f.file_name() + .is_some_and(|s| s.display().to_string() == "Dockerfile.extended") + }) + .expect("to be found"); + let feature_dockerfile = test_dependencies.fs.load(feature_dockerfile).await.unwrap(); + assert_eq!( + &feature_dockerfile, + r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder + +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +ARG VARIANT="16-bullseye" +FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} AS dev_container_auto_added_stage_label + +RUN mkdir -p /workspaces && chown node:node /workspaces + +ARG USERNAME=node +USER $USERNAME + +# Save command line history +RUN echo "export HISTFILE=/home/$USERNAME/commandhistory/.bash_history" >> "/home/$USERNAME/.bashrc" \ +&& echo "export PROMPT_COMMAND='history -a'" >> "/home/$USERNAME/.bashrc" \ +&& mkdir -p /home/$USERNAME/commandhistory \ +&& touch /home/$USERNAME/commandhistory/.bash_history \ +&& chown -R $USERNAME /home/$USERNAME/commandhistory + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize +USER root +COPY --from=dev_containers_feature_content_source ./devcontainer-features.builtin.env /tmp/build-features/ +RUN chmod -R 0755 /tmp/build-features/ + +FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage + +USER root + +RUN mkdir -p /tmp/dev-container-features +COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ /tmp/dev-container-features + +RUN \ +echo "_CONTAINER_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'root' || grep -E '^root|^[^:]*:[^:]*:root:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && \ +echo "_REMOTE_USER_HOME=$( (command -v getent >/dev/null 2>&1 && getent passwd 'node' || grep -E '^node|^[^:]*:[^:]*:node:' /etc/passwd || true) | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env + + +RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./docker-in-docker_0,target=/tmp/build-features-src/docker-in-docker_0 \ +cp -ar /tmp/build-features-src/docker-in-docker_0 /tmp/dev-container-features \ +&& chmod -R 0755 /tmp/dev-container-features/docker-in-docker_0 \ +&& cd /tmp/dev-container-features/docker-in-docker_0 \ +&& chmod +x ./devcontainer-features-install.sh \ +&& ./devcontainer-features-install.sh \ +&& rm -rf /tmp/dev-container-features/docker-in-docker_0 + +RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./go_1,target=/tmp/build-features-src/go_1 \ +cp -ar /tmp/build-features-src/go_1 /tmp/dev-container-features \ +&& chmod -R 0755 /tmp/dev-container-features/go_1 \ +&& cd /tmp/dev-container-features/go_1 \ +&& chmod +x ./devcontainer-features-install.sh \ +&& ./devcontainer-features-install.sh \ +&& rm -rf /tmp/dev-container-features/go_1 + + +ARG _DEV_CONTAINERS_IMAGE_USER=root +USER $_DEV_CONTAINERS_IMAGE_USER + +# Ensure that /etc/profile does not clobber the existing path +RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true + +ENV DOCKER_BUILDKIT=1 + +ENV GOPATH=/go +ENV GOROOT=/usr/local/go +ENV PATH=/usr/local/go/bin:/go/bin:${PATH} +ENV VARIABLE_VALUE=value +"# + ); + + let golang_install_wrapper = files + .iter() + .find(|f| { + f.file_name() + .is_some_and(|s| s.display().to_string() == "devcontainer-features-install.sh") + && f.to_str().is_some_and(|s| s.contains("go_")) + }) + .expect("to be found"); + let golang_install_wrapper = test_dependencies + .fs + .load(golang_install_wrapper) + .await + .unwrap(); + assert_eq!( + &golang_install_wrapper, + r#"#!/bin/sh +set -e + +on_exit () { + [ $? -eq 0 ] && exit + echo 'ERROR: Feature "go" (ghcr.io/devcontainers/features/go:1) failed to install!' +} + +trap on_exit EXIT + +echo =========================================================================== +echo 'Feature : go' +echo 'Id : ghcr.io/devcontainers/features/go:1' +echo 'Options :' +echo ' GOLANGCILINTVERSION=latest + VERSION=latest' +echo =========================================================================== + +set -a +. ../devcontainer-features.builtin.env +. ./devcontainer-features.env +set +a + +chmod +x ./install.sh +./install.sh +"# + ); + + let docker_commands = test_dependencies + .command_runner + .commands_by_program("docker"); + + let docker_run_command = docker_commands + .iter() + .find(|c| c.args.get(0).is_some_and(|a| a == "run")); + + assert!(docker_run_command.is_some()); + + let docker_exec_commands = test_dependencies + .docker + .exec_commands_recorded + .lock() + .unwrap(); + + assert!(docker_exec_commands.iter().all(|exec| { + exec.env + == HashMap::from([ + ("OTHER_ENV".to_string(), "other_env_value".to_string()), + ( + "PATH".to_string(), + "/initial/path:/some/other/path".to_string(), + ), + ]) + })) + } + + pub(crate) struct RecordedExecCommand { + pub(crate) _container_id: String, + pub(crate) _remote_folder: String, + pub(crate) _user: String, + pub(crate) env: HashMap, + pub(crate) _inner_command: Command, + } + + pub(crate) struct FakeDocker { + exec_commands_recorded: Mutex>, + podman: bool, + } + + impl FakeDocker { + pub(crate) fn new() -> Self { + Self { + podman: false, + exec_commands_recorded: Mutex::new(Vec::new()), + } + } + #[cfg(not(target_os = "windows"))] + fn set_podman(&mut self, podman: bool) { + self.podman = podman; + } + } + + #[async_trait] + impl DockerClient for FakeDocker { + async fn inspect(&self, id: &String) -> Result { + if id == "mcr.microsoft.com/devcontainers/typescript-node:1-18-bookworm" { + return Ok(DockerInspect { + id: "sha256:610e6cfca95280188b021774f8cf69dd6f49bdb6eebc34c5ee2010f4d51cc104" + .to_string(), + config: DockerInspectConfig { + labels: DockerConfigLabels { + metadata: Some(vec![HashMap::from([( + "remoteUser".to_string(), + Value::String("node".to_string()), + )])]), + }, + env: Vec::new(), + image_user: Some("root".to_string()), + }, + mounts: None, + state: None, + }); + } + if id == "mcr.microsoft.com/devcontainers/rust:2-1-bookworm" { + return Ok(DockerInspect { + id: "sha256:39ad1c7264794d60e3bc449d9d8877a8e486d19ad8fba80f5369def6a2408392" + .to_string(), + config: DockerInspectConfig { + labels: DockerConfigLabels { + metadata: Some(vec![HashMap::from([( + "remoteUser".to_string(), + Value::String("vscode".to_string()), + )])]), + }, + image_user: Some("root".to_string()), + env: Vec::new(), + }, + mounts: None, + state: None, + }); + } + if id.starts_with("cli_") { + return Ok(DockerInspect { + id: "sha256:610e6cfca95280188b021774f8cf69dd6f49bdb6eebc34c5ee2010f4d51cc105" + .to_string(), + config: DockerInspectConfig { + labels: DockerConfigLabels { + metadata: Some(vec![HashMap::from([( + "remoteUser".to_string(), + Value::String("node".to_string()), + )])]), + }, + image_user: Some("root".to_string()), + env: vec!["PATH=/initial/path".to_string()], + }, + mounts: None, + state: None, + }); + } + if id == "found_docker_ps" { + return Ok(DockerInspect { + id: "sha256:610e6cfca95280188b021774f8cf69dd6f49bdb6eebc34c5ee2010f4d51cc105" + .to_string(), + config: DockerInspectConfig { + labels: DockerConfigLabels { + metadata: Some(vec![HashMap::from([( + "remoteUser".to_string(), + Value::String("node".to_string()), + )])]), + }, + image_user: Some("root".to_string()), + env: vec!["PATH=/initial/path".to_string()], + }, + mounts: Some(vec![DockerInspectMount { + source: "/path/to/local/project".to_string(), + destination: "/workspaces/project".to_string(), + }]), + state: None, + }); + } + if id.starts_with("rust_a-") { + return Ok(DockerInspect { + id: "sha256:9da65c34ab809e763b13d238fd7a0f129fcabd533627d340f293308cb63620a0" + .to_string(), + config: DockerInspectConfig { + labels: DockerConfigLabels { + metadata: Some(vec![HashMap::from([( + "remoteUser".to_string(), + Value::String("vscode".to_string()), + )])]), + }, + image_user: Some("root".to_string()), + env: Vec::new(), + }, + mounts: None, + state: None, + }); + } + + Err(DevContainerError::DockerNotAvailable) + } + async fn get_docker_compose_config( + &self, + config_files: &Vec, + ) -> Result, DevContainerError> { + if config_files.len() == 1 + && config_files.get(0) + == Some(&PathBuf::from( + "/path/to/local/project/.devcontainer/docker-compose.yml", + )) + { + return Ok(Some(DockerComposeConfig { + name: None, + services: HashMap::from([ + ( + "app".to_string(), + DockerComposeService { + build: Some(DockerComposeServiceBuild { + context: Some(".".to_string()), + dockerfile: Some("Dockerfile".to_string()), + args: None, + additional_contexts: None, + }), + volumes: vec![MountDefinition { + source: "../..".to_string(), + target: "/workspaces".to_string(), + mount_type: Some("bind".to_string()), + }], + network_mode: Some("service:db".to_string()), + ..Default::default() + }, + ), + ( + "db".to_string(), + DockerComposeService { + image: Some("postgres:14.1".to_string()), + volumes: vec![MountDefinition { + source: "postgres-data".to_string(), + target: "/var/lib/postgresql/data".to_string(), + mount_type: Some("volume".to_string()), + }], + env_file: Some(vec![".env".to_string()]), + ..Default::default() + }, + ), + ]), + volumes: HashMap::from([( + "postgres-data".to_string(), + DockerComposeVolume::default(), + )]), + })); + } + Err(DevContainerError::DockerNotAvailable) + } + async fn docker_compose_build( + &self, + _config_files: &Vec, + _project_name: &str, + ) -> Result<(), DevContainerError> { + Ok(()) + } + async fn run_docker_exec( + &self, + container_id: &str, + remote_folder: &str, + user: &str, + env: &HashMap, + inner_command: Command, + ) -> Result<(), DevContainerError> { + let mut record = self + .exec_commands_recorded + .lock() + .expect("should be available"); + record.push(RecordedExecCommand { + _container_id: container_id.to_string(), + _remote_folder: remote_folder.to_string(), + _user: user.to_string(), + env: env.clone(), + _inner_command: inner_command, + }); + Ok(()) + } + async fn start_container(&self, _id: &str) -> Result<(), DevContainerError> { + Err(DevContainerError::DockerNotAvailable) + } + async fn find_process_by_filters( + &self, + _filters: Vec, + ) -> Result, DevContainerError> { + Ok(Some(DockerPs { + id: "found_docker_ps".to_string(), + })) + } + fn supports_compose_buildkit(&self) -> bool { + !self.podman + } + fn docker_cli(&self) -> String { + if self.podman { + "podman".to_string() + } else { + "docker".to_string() + } + } + } + + #[derive(Debug, Clone)] + pub(crate) struct TestCommand { + pub(crate) program: String, + pub(crate) args: Vec, + } + + pub(crate) struct TestCommandRunner { + commands_recorded: Mutex>, + } + + impl TestCommandRunner { + fn new() -> Self { + Self { + commands_recorded: Mutex::new(Vec::new()), + } + } + + fn commands_by_program(&self, program: &str) -> Vec { + let record = self.commands_recorded.lock().expect("poisoned"); + record + .iter() + .filter(|r| r.program == program) + .map(|r| r.clone()) + .collect() + } + } + + #[async_trait] + impl CommandRunner for TestCommandRunner { + async fn run_command(&self, command: &mut Command) -> Result { + let mut record = self.commands_recorded.lock().expect("poisoned"); + + record.push(TestCommand { + program: command.get_program().display().to_string(), + args: command + .get_args() + .map(|a| a.display().to_string()) + .collect(), + }); + + Ok(Output { + status: ExitStatus::default(), + stdout: vec![], + stderr: vec![], + }) + } + } + + fn fake_http_client() -> Arc { + FakeHttpClient::create(|request| async move { + let (parts, _body) = request.into_parts(); + if parts.uri.path() == "/token" { + let token_response = TokenResponse { + token: "token".to_string(), + }; + return Ok(http::Response::builder() + .status(200) + .body(http_client::AsyncBody::from( + serde_json_lenient::to_string(&token_response).unwrap(), + )) + .unwrap()); + } + + // OCI specific things + if parts.uri.path() == "/v2/devcontainers/features/docker-in-docker/manifests/2" { + let response = r#" + { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.devcontainers", + "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "size": 2 + }, + "layers": [ + { + "mediaType": "application/vnd.devcontainers.layer.v1+tar", + "digest": "sha256:bc7ab0d8d8339416e1491419ab9ffe931458d0130110f4b18351b0fa184e67d5", + "size": 59392, + "annotations": { + "org.opencontainers.image.title": "devcontainer-feature-docker-in-docker.tgz" + } + } + ], + "annotations": { + "dev.containers.metadata": "{\"id\":\"docker-in-docker\",\"version\":\"2.16.1\",\"name\":\"Docker (Docker-in-Docker)\",\"documentationURL\":\"https://github.com/devcontainers/features/tree/main/src/docker-in-docker\",\"description\":\"Create child containers *inside* a container, independent from the host's docker instance. Installs Docker extension in the container along with needed CLIs.\",\"options\":{\"version\":{\"type\":\"string\",\"proposals\":[\"latest\",\"none\",\"20.10\"],\"default\":\"latest\",\"description\":\"Select or enter a Docker/Moby Engine version. (Availability can vary by OS version.)\"},\"moby\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Install OSS Moby build instead of Docker CE\"},\"mobyBuildxVersion\":{\"type\":\"string\",\"default\":\"latest\",\"description\":\"Install a specific version of moby-buildx when using Moby\"},\"dockerDashComposeVersion\":{\"type\":\"string\",\"enum\":[\"none\",\"v1\",\"v2\"],\"default\":\"v2\",\"description\":\"Default version of Docker Compose (v1, v2 or none)\"},\"azureDnsAutoDetection\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Allow automatically setting the dockerd DNS server when the installation script detects it is running in Azure\"},\"dockerDefaultAddressPool\":{\"type\":\"string\",\"default\":\"\",\"proposals\":[],\"description\":\"Define default address pools for Docker networks. e.g. base=192.168.0.0/16,size=24\"},\"installDockerBuildx\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Install Docker Buildx\"},\"installDockerComposeSwitch\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Install Compose Switch (provided docker compose is available) which is a replacement to the Compose V1 docker-compose (python) executable. It translates the command line into Compose V2 docker compose then runs the latter.\"},\"disableIp6tables\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Disable ip6tables (this option is only applicable for Docker versions 27 and greater)\"}},\"entrypoint\":\"/usr/local/share/docker-init.sh\",\"privileged\":true,\"containerEnv\":{\"DOCKER_BUILDKIT\":\"1\"},\"customizations\":{\"vscode\":{\"extensions\":[\"ms-azuretools.vscode-containers\"],\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes the Docker CLI (`docker`) pre-installed and available on the `PATH` for running and managing containers using a dedicated Docker daemon running inside the dev container.\"}]}}},\"mounts\":[{\"source\":\"dind-var-lib-docker-${devcontainerId}\",\"target\":\"/var/lib/docker\",\"type\":\"volume\"}],\"installsAfter\":[\"ghcr.io/devcontainers/features/common-utils\"]}", + "com.github.package.type": "devcontainer_feature" + } + } + "#; + return Ok(http::Response::builder() + .status(200) + .body(http_client::AsyncBody::from(response)) + .unwrap()); + } + + if parts.uri.path() + == "/v2/devcontainers/features/docker-in-docker/blobs/sha256:bc7ab0d8d8339416e1491419ab9ffe931458d0130110f4b18351b0fa184e67d5" + { + let response = build_tarball(vec![ + ("./NOTES.md", r#" + ## Limitations + + This docker-in-docker Dev Container Feature is roughly based on the [official docker-in-docker wrapper script](https://github.com/moby/moby/blob/master/hack/dind) that is part of the [Moby project](https://mobyproject.org/). With this in mind: + * As the name implies, the Feature is expected to work when the host is running Docker (or the OSS Moby container engine it is built on). It may be possible to get running in other container engines, but it has not been tested with them. + * The host and the container must be running on the same chip architecture. You will not be able to use it with an emulated x86 image with Docker Desktop on an Apple Silicon Mac, like in this example: + ``` + FROM --platform=linux/amd64 mcr.microsoft.com/devcontainers/typescript-node:16 + ``` + See [Issue #219](https://github.com/devcontainers/features/issues/219) for more details. + + + ## OS Support + + This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + + Debian Trixie (13) does not include moby-cli and related system packages, so the feature cannot install with "moby": "true". To use this feature on Trixie, please set "moby": "false" or choose a different base image (for example, Ubuntu 24.04). + + `bash` is required to execute the `install.sh` script."#), + ("./README.md", r#" + # Docker (Docker-in-Docker) (docker-in-docker) + + Create child containers *inside* a container, independent from the host's docker instance. Installs Docker extension in the container along with needed CLIs. + + ## Example Usage + + ```json + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {} + } + ``` + + ## Options + + | Options Id | Description | Type | Default Value | + |-----|-----|-----|-----| + | version | Select or enter a Docker/Moby Engine version. (Availability can vary by OS version.) | string | latest | + | moby | Install OSS Moby build instead of Docker CE | boolean | true | + | mobyBuildxVersion | Install a specific version of moby-buildx when using Moby | string | latest | + | dockerDashComposeVersion | Default version of Docker Compose (v1, v2 or none) | string | v2 | + | azureDnsAutoDetection | Allow automatically setting the dockerd DNS server when the installation script detects it is running in Azure | boolean | true | + | dockerDefaultAddressPool | Define default address pools for Docker networks. e.g. base=192.168.0.0/16,size=24 | string | - | + | installDockerBuildx | Install Docker Buildx | boolean | true | + | installDockerComposeSwitch | Install Compose Switch (provided docker compose is available) which is a replacement to the Compose V1 docker-compose (python) executable. It translates the command line into Compose V2 docker compose then runs the latter. | boolean | false | + | disableIp6tables | Disable ip6tables (this option is only applicable for Docker versions 27 and greater) | boolean | false | + + ## Customizations + + ### VS Code Extensions + + - `ms-azuretools.vscode-containers` + + ## Limitations + + This docker-in-docker Dev Container Feature is roughly based on the [official docker-in-docker wrapper script](https://github.com/moby/moby/blob/master/hack/dind) that is part of the [Moby project](https://mobyproject.org/). With this in mind: + * As the name implies, the Feature is expected to work when the host is running Docker (or the OSS Moby container engine it is built on). It may be possible to get running in other container engines, but it has not been tested with them. + * The host and the container must be running on the same chip architecture. You will not be able to use it with an emulated x86 image with Docker Desktop on an Apple Silicon Mac, like in this example: + ``` + FROM --platform=linux/amd64 mcr.microsoft.com/devcontainers/typescript-node:16 + ``` + See [Issue #219](https://github.com/devcontainers/features/issues/219) for more details. + + + ## OS Support + + This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed. + + `bash` is required to execute the `install.sh` script. + + + --- + + _Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/docker-in-docker/devcontainer-feature.json). Add additional notes to a `NOTES.md`._"#), + ("./devcontainer-feature.json", r#" + { + "id": "docker-in-docker", + "version": "2.16.1", + "name": "Docker (Docker-in-Docker)", + "documentationURL": "https://github.com/devcontainers/features/tree/main/src/docker-in-docker", + "description": "Create child containers *inside* a container, independent from the host's docker instance. Installs Docker extension in the container along with needed CLIs.", + "options": { + "version": { + "type": "string", + "proposals": [ + "latest", + "none", + "20.10" + ], + "default": "latest", + "description": "Select or enter a Docker/Moby Engine version. (Availability can vary by OS version.)" + }, + "moby": { + "type": "boolean", + "default": true, + "description": "Install OSS Moby build instead of Docker CE" + }, + "mobyBuildxVersion": { + "type": "string", + "default": "latest", + "description": "Install a specific version of moby-buildx when using Moby" + }, + "dockerDashComposeVersion": { + "type": "string", + "enum": [ + "none", + "v1", + "v2" + ], + "default": "v2", + "description": "Default version of Docker Compose (v1, v2 or none)" + }, + "azureDnsAutoDetection": { + "type": "boolean", + "default": true, + "description": "Allow automatically setting the dockerd DNS server when the installation script detects it is running in Azure" + }, + "dockerDefaultAddressPool": { + "type": "string", + "default": "", + "proposals": [], + "description": "Define default address pools for Docker networks. e.g. base=192.168.0.0/16,size=24" + }, + "installDockerBuildx": { + "type": "boolean", + "default": true, + "description": "Install Docker Buildx" + }, + "installDockerComposeSwitch": { + "type": "boolean", + "default": false, + "description": "Install Compose Switch (provided docker compose is available) which is a replacement to the Compose V1 docker-compose (python) executable. It translates the command line into Compose V2 docker compose then runs the latter." + }, + "disableIp6tables": { + "type": "boolean", + "default": false, + "description": "Disable ip6tables (this option is only applicable for Docker versions 27 and greater)" + } + }, + "entrypoint": "/usr/local/share/docker-init.sh", + "privileged": true, + "containerEnv": { + "DOCKER_BUILDKIT": "1" + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-azuretools.vscode-containers" + ], + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the Docker CLI (`docker`) pre-installed and available on the `PATH` for running and managing containers using a dedicated Docker daemon running inside the dev container." + } + ] + } + } + }, + "mounts": [ + { + "source": "dind-var-lib-docker-${devcontainerId}", + "target": "/var/lib/docker", + "type": "volume" + } + ], + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] + }"#), + ("./install.sh", r#" + #!/usr/bin/env bash + #------------------------------------------------------------------------------------------------------------- + # Copyright (c) Microsoft Corporation. All rights reserved. + # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. + #------------------------------------------------------------------------------------------------------------- + # + # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/docker-in-docker.md + # Maintainer: The Dev Container spec maintainers + + + DOCKER_VERSION="${VERSION:-"latest"}" # The Docker/Moby Engine + CLI should match in version + USE_MOBY="${MOBY:-"true"}" + MOBY_BUILDX_VERSION="${MOBYBUILDXVERSION:-"latest"}" + DOCKER_DASH_COMPOSE_VERSION="${DOCKERDASHCOMPOSEVERSION:-"v2"}" #v1, v2 or none + AZURE_DNS_AUTO_DETECTION="${AZUREDNSAUTODETECTION:-"true"}" + DOCKER_DEFAULT_ADDRESS_POOL="${DOCKERDEFAULTADDRESSPOOL:-""}" + USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" + INSTALL_DOCKER_BUILDX="${INSTALLDOCKERBUILDX:-"true"}" + INSTALL_DOCKER_COMPOSE_SWITCH="${INSTALLDOCKERCOMPOSESWITCH:-"false"}" + MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc" + MICROSOFT_GPG_KEYS_ROLLING_URI="https://packages.microsoft.com/keys/microsoft-rolling.asc" + DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES="trixie bookworm buster bullseye bionic focal jammy noble" + DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES="trixie bookworm buster bullseye bionic focal hirsute impish jammy noble" + DISABLE_IP6_TABLES="${DISABLEIP6TABLES:-false}" + + # Default: Exit on any failure. + set -e + + # Clean up + rm -rf /var/lib/apt/lists/* + + # Setup STDERR. + err() { + echo "(!) $*" >&2 + } + + if [ "$(id -u)" -ne 0 ]; then + err 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 + fi + + ################### + # Helper Functions + # See: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/shared/utils.sh + ################### + + # Determine the appropriate non-root user + if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + USERNAME="" + POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do + if id -u ${CURRENT_USER} > /dev/null 2>&1; then + USERNAME=${CURRENT_USER} + break + fi + done + if [ "${USERNAME}" = "" ]; then + USERNAME=root + fi + elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then + USERNAME=root + fi + + # Package manager update function + pkg_mgr_update() { + case ${ADJUSTED_ID} in + debian) + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi + ;; + rhel) + if [ ${PKG_MGR_CMD} = "microdnf" ]; then + cache_check_dir="/var/cache/yum" + else + cache_check_dir="/var/cache/${PKG_MGR_CMD}" + fi + if [ "$(ls ${cache_check_dir}/* 2>/dev/null | wc -l)" = 0 ]; then + echo "Running ${PKG_MGR_CMD} makecache ..." + ${PKG_MGR_CMD} makecache + fi + ;; + esac + } + + # Checks if packages are installed and installs them if not + check_packages() { + case ${ADJUSTED_ID} in + debian) + if ! dpkg -s "$@" > /dev/null 2>&1; then + pkg_mgr_update + apt-get -y install --no-install-recommends "$@" + fi + ;; + rhel) + if ! rpm -q "$@" > /dev/null 2>&1; then + pkg_mgr_update + ${PKG_MGR_CMD} -y install "$@" + fi + ;; + esac + } + + # Figure out correct version of a three part version number is not passed + find_version_from_git_tags() { + local variable_name=$1 + local requested_version=${!variable_name} + if [ "${requested_version}" = "none" ]; then return; fi + local repository=$2 + local prefix=${3:-"tags/v"} + local separator=${4:-"."} + local last_part_optional=${5:-"false"} + if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then + local escaped_separator=${separator//./\\.} + local last_part + if [ "${last_part_optional}" = "true" ]; then + last_part="(${escaped_separator}[0-9]+)?" + else + last_part="${escaped_separator}[0-9]+" + fi + local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" + local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then + declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" + else + set +e + declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" + set -e + fi + fi + if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then + err "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 + exit 1 + fi + echo "${variable_name}=${!variable_name}" + } + + # Use semver logic to decrement a version number then look for the closest match + find_prev_version_from_git_tags() { + local variable_name=$1 + local current_version=${!variable_name} + local repository=$2 + # Normally a "v" is used before the version number, but support alternate cases + local prefix=${3:-"tags/v"} + # Some repositories use "_" instead of "." for version number part separation, support that + local separator=${4:-"."} + # Some tools release versions that omit the last digit (e.g. go) + local last_part_optional=${5:-"false"} + # Some repositories may have tags that include a suffix (e.g. actions/node-versions) + local version_suffix_regex=$6 + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + + if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then + ((major=major-1)) + declare -g ${variable_name}="${major}" + # Look for latest version from previous major release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + # Handle situations like Go's odd version pattern where "0" releases omit the last part + elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + declare -g ${variable_name}="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then + declare -g ${variable_name}="${major}.${minor}" + else + declare -g ${variable_name}="${major}.${minor}.${breakfix}" + fi + fi + set -e + } + + # Function to fetch the version released prior to the latest version + get_previous_version() { + local url=$1 + local repo_url=$2 + local variable_name=$3 + prev_version=${!variable_name} + + output=$(curl -s "$repo_url"); + if echo "$output" | jq -e 'type == "object"' > /dev/null; then + message=$(echo "$output" | jq -r '.message') + + if [[ $message == "API rate limit exceeded"* ]]; then + echo -e "\nAn attempt to find latest version using GitHub Api Failed... \nReason: ${message}" + echo -e "\nAttempting to find latest version using GitHub tags." + find_prev_version_from_git_tags prev_version "$url" "tags/v" + declare -g ${variable_name}="${prev_version}" + fi + elif echo "$output" | jq -e 'type == "array"' > /dev/null; then + echo -e "\nAttempting to find latest version using GitHub Api." + version=$(echo "$output" | jq -r '.[1].tag_name') + declare -g ${variable_name}="${version#v}" + fi + echo "${variable_name}=${!variable_name}" + } + + get_github_api_repo_url() { + local url=$1 + echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases" + } + + ########################################### + # Start docker-in-docker installation + ########################################### + + # Ensure apt is in non-interactive to avoid prompts + export DEBIAN_FRONTEND=noninteractive + + # Source /etc/os-release to get OS info + . /etc/os-release + + # Determine adjusted ID and package manager + if [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ]; then + ADJUSTED_ID="debian" + PKG_MGR_CMD="apt-get" + # Use dpkg for Debian-based systems + architecture="$(dpkg --print-architecture 2>/dev/null || uname -m)" + elif [[ "${ID}" = "rhel" || "${ID}" = "fedora" || "${ID}" = "azurelinux" || "${ID}" = "mariner" || "${ID_LIKE}" = *"rhel"* || "${ID_LIKE}" = *"fedora"* || "${ID_LIKE}" = *"azurelinux"* || "${ID_LIKE}" = *"mariner"* ]]; then + ADJUSTED_ID="rhel" + # Determine the appropriate package manager for RHEL-based systems + for pkg_mgr in tdnf dnf microdnf yum; do + if command -v "$pkg_mgr" >/dev/null 2>&1; then + PKG_MGR_CMD="$pkg_mgr" + break + fi + done + + if [ -z "${PKG_MGR_CMD}" ]; then + err "Unable to find a supported package manager (tdnf, dnf, microdnf, yum)" + exit 1 + fi + + architecture="$(rpm --eval '%{_arch}' 2>/dev/null || uname -m)" + else + err "Linux distro ${ID} not supported." + exit 1 + fi + + # Azure Linux specific setup + if [ "${ID}" = "azurelinux" ]; then + VERSION_CODENAME="azurelinux${VERSION_ID}" + fi + + # Prevent attempting to install Moby on Debian trixie (packages removed) + if [ "${USE_MOBY}" = "true" ] && [ "${ID}" = "debian" ] && [ "${VERSION_CODENAME}" = "trixie" ]; then + err "The 'moby' option is not supported on Debian 'trixie' because 'moby-cli' and related system packages have been removed from that distribution." + err "To continue, either set the feature option '\"moby\": false' or use a different base image (for example: 'debian:bookworm' or 'ubuntu-24.04')." + exit 1 + fi + + # Check if distro is supported + if [ "${USE_MOBY}" = "true" ]; then + if [ "${ADJUSTED_ID}" = "debian" ]; then + if [[ "${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then + err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS distribution" + err "Supported distributions include: ${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}" + exit 1 + fi + echo "(*) ${VERSION_CODENAME} is supported for Moby installation - setting up Microsoft repository" + elif [ "${ADJUSTED_ID}" = "rhel" ]; then + if [ "${ID}" = "azurelinux" ] || [ "${ID}" = "mariner" ]; then + echo " (*) ${ID} ${VERSION_ID} detected - using Microsoft repositories for Moby packages" + else + echo "RHEL-based system (${ID}) detected - Moby packages may require additional configuration" + fi + fi + else + if [ "${ADJUSTED_ID}" = "debian" ]; then + if [[ "${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then + err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, please choose a compatible OS distribution" + err "Supported distributions include: ${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}" + exit 1 + fi + echo "(*) ${VERSION_CODENAME} is supported for Docker CE installation (supported: ${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}) - setting up Docker repository" + elif [ "${ADJUSTED_ID}" = "rhel" ]; then + + echo "RHEL-based system (${ID}) detected - using Docker CE packages" + fi + fi + + # Install base dependencies + base_packages="curl ca-certificates pigz iptables gnupg2 wget jq" + case ${ADJUSTED_ID} in + debian) + check_packages apt-transport-https $base_packages dirmngr + ;; + rhel) + check_packages $base_packages tar gawk shadow-utils policycoreutils procps-ng systemd-libs systemd-devel + + ;; + esac + + # Install git if not already present + if ! command -v git >/dev/null 2>&1; then + check_packages git + fi + + # Update CA certificates to ensure HTTPS connections work properly + # This is especially important for Ubuntu 24.04 (Noble) and Debian Trixie + # Only run for Debian-based systems (RHEL uses update-ca-trust instead) + if [ "${ADJUSTED_ID}" = "debian" ] && command -v update-ca-certificates > /dev/null 2>&1; then + update-ca-certificates + fi + + # Swap to legacy iptables for compatibility (Debian only) + if [ "${ADJUSTED_ID}" = "debian" ] && type iptables-legacy > /dev/null 2>&1; then + update-alternatives --set iptables /usr/sbin/iptables-legacy + update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy + fi + + # Set up the necessary repositories + if [ "${USE_MOBY}" = "true" ]; then + # Name of open source engine/cli + engine_package_name="moby-engine" + cli_package_name="moby-cli" + + case ${ADJUSTED_ID} in + debian) + # Import key safely and import Microsoft apt repo + { + curl -sSL ${MICROSOFT_GPG_KEYS_URI} + curl -sSL ${MICROSOFT_GPG_KEYS_ROLLING_URI} + } | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg + echo "deb [arch=${architecture} signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/microsoft-${ID}-${VERSION_CODENAME}-prod ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/microsoft.list + ;; + rhel) + echo "(*) ${ID} detected - checking for Moby packages..." + + # Check if moby packages are available in default repos + if ${PKG_MGR_CMD} list available moby-engine >/dev/null 2>&1; then + echo "(*) Using built-in ${ID} Moby packages" + else + case "${ID}" in + azurelinux) + echo "(*) Moby packages not found in Azure Linux repositories" + echo "(*) For Azure Linux, Docker CE ('moby': false) is recommended" + err "Moby packages are not available for Azure Linux ${VERSION_ID}." + err "Recommendation: Use '\"moby\": false' to install Docker CE instead." + exit 1 + ;; + mariner) + echo "(*) Adding Microsoft repository for CBL-Mariner..." + # Add Microsoft repository if packages aren't available locally + curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /etc/pki/rpm-gpg/microsoft.gpg + cat > /etc/yum.repos.d/microsoft.repo << EOF + [microsoft] + name=Microsoft Repository + baseurl=https://packages.microsoft.com/repos/microsoft-cbl-mariner-2.0-prod-base/ + enabled=1 + gpgcheck=1 + gpgkey=file:///etc/pki/rpm-gpg/microsoft.gpg + EOF + # Verify packages are available after adding repo + pkg_mgr_update + if ! ${PKG_MGR_CMD} list available moby-engine >/dev/null 2>&1; then + echo "(*) Moby packages not found in Microsoft repository either" + err "Moby packages are not available for CBL-Mariner ${VERSION_ID}." + err "Recommendation: Use '\"moby\": false' to install Docker CE instead." + exit 1 + fi + ;; + *) + err "Moby packages are not available for ${ID}. Please use 'moby': false option." + exit 1 + ;; + esac + fi + ;; + esac + else + # Name of licensed engine/cli + engine_package_name="docker-ce" + cli_package_name="docker-ce-cli" + case ${ADJUSTED_ID} in + debian) + curl -fsSL https://download.docker.com/linux/${ID}/gpg | gpg --dearmor > /usr/share/keyrings/docker-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list + ;; + rhel) + # Docker CE repository setup for RHEL-based systems + setup_docker_ce_repo() { + curl -fsSL https://download.docker.com/linux/centos/gpg > /etc/pki/rpm-gpg/docker-ce.gpg + cat > /etc/yum.repos.d/docker-ce.repo << EOF + [docker-ce-stable] + name=Docker CE Stable + baseurl=https://download.docker.com/linux/centos/9/\$basearch/stable + enabled=1 + gpgcheck=1 + gpgkey=file:///etc/pki/rpm-gpg/docker-ce.gpg + skip_if_unavailable=1 + module_hotfixes=1 + EOF + } + install_azure_linux_deps() { + echo "(*) Installing device-mapper libraries for Docker CE..." + [ "${ID}" != "mariner" ] && ${PKG_MGR_CMD} -y install device-mapper-libs 2>/dev/null || echo "(*) Device-mapper install failed, proceeding" + echo "(*) Installing additional Docker CE dependencies..." + ${PKG_MGR_CMD} -y install libseccomp libtool-ltdl systemd-libs libcgroup tar xz || { + echo "(*) Some optional dependencies could not be installed, continuing..." + } + } + setup_selinux_context() { + if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null)" != "Disabled" ]; then + echo "(*) Creating minimal SELinux context for Docker compatibility..." + mkdir -p /etc/selinux/targeted/contexts/files/ 2>/dev/null || true + echo "/var/lib/docker(/.*)? system_u:object_r:container_file_t:s0" >> /etc/selinux/targeted/contexts/files/file_contexts.local 2>/dev/null || true + fi + } + + # Special handling for RHEL Docker CE installation + case "${ID}" in + azurelinux|mariner) + echo "(*) ${ID} detected" + echo "(*) Note: Moby packages work better on Azure Linux. Consider using 'moby': true" + echo "(*) Setting up Docker CE repository..." + + setup_docker_ce_repo + install_azure_linux_deps + + if [ "${USE_MOBY}" != "true" ]; then + echo "(*) Docker CE installation for Azure Linux - skipping container-selinux" + echo "(*) Note: SELinux policies will be minimal but Docker will function normally" + setup_selinux_context + else + echo "(*) Using Moby - container-selinux not required" + fi + ;; + *) + # Standard RHEL/CentOS/Fedora approach + if command -v dnf >/dev/null 2>&1; then + dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo + elif command -v yum-config-manager >/dev/null 2>&1; then + yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo + else + # Manual fallback + setup_docker_ce_repo + fi + ;; + esac + ;; + esac + fi + + # Refresh package database + case ${ADJUSTED_ID} in + debian) + apt-get update + ;; + rhel) + pkg_mgr_update + ;; + esac + + # Soft version matching + if [ "${DOCKER_VERSION}" = "latest" ] || [ "${DOCKER_VERSION}" = "lts" ] || [ "${DOCKER_VERSION}" = "stable" ]; then + # Empty, meaning grab whatever "latest" is in apt repo + engine_version_suffix="" + cli_version_suffix="" + else + case ${ADJUSTED_ID} in + debian) + # Fetch a valid version from the apt-cache (eg: the Microsoft repo appends +azure, breakfix, etc...) + docker_version_dot_escaped="${DOCKER_VERSION//./\\.}" + docker_version_dot_plus_escaped="${docker_version_dot_escaped//+/\\+}" + # Regex needs to handle debian package version number format: https://www.systutorials.com/docs/linux/man/5-deb-version/ + docker_version_regex="^(.+:)?${docker_version_dot_plus_escaped}([\\.\\+ ~:-]|$)" + set +e # Don't exit if finding version fails - will handle gracefully + cli_version_suffix="=$(apt-cache madison ${cli_package_name} | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${docker_version_regex}")" + engine_version_suffix="=$(apt-cache madison ${engine_package_name} | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${docker_version_regex}")" + set -e + if [ -z "${engine_version_suffix}" ] || [ "${engine_version_suffix}" = "=" ] || [ -z "${cli_version_suffix}" ] || [ "${cli_version_suffix}" = "=" ] ; then + err "No full or partial Docker / Moby version match found for \"${DOCKER_VERSION}\" on OS ${ID} ${VERSION_CODENAME} (${architecture}). Available versions:" + apt-cache madison ${cli_package_name} | awk -F"|" '{print $2}' | grep -oP '^(.+:)?\K.+' + exit 1 + fi + ;; + rhel) + # For RHEL-based systems, use dnf/yum to find versions + docker_version_escaped="${DOCKER_VERSION//./\\.}" + set +e # Don't exit if finding version fails - will handle gracefully + if [ "${USE_MOBY}" = "true" ]; then + available_versions=$(${PKG_MGR_CMD} list --available moby-engine 2>/dev/null | grep -v "Available Packages" | awk '{print $2}' | grep -E "^${docker_version_escaped}" | head -1) + else + available_versions=$(${PKG_MGR_CMD} list --available docker-ce 2>/dev/null | grep -v "Available Packages" | awk '{print $2}' | grep -E "^${docker_version_escaped}" | head -1) + fi + set -e + if [ -n "${available_versions}" ]; then + engine_version_suffix="-${available_versions}" + cli_version_suffix="-${available_versions}" + else + echo "(*) Exact version ${DOCKER_VERSION} not found, using latest available" + engine_version_suffix="" + cli_version_suffix="" + fi + ;; + esac + fi + + # Version matching for moby-buildx + if [ "${USE_MOBY}" = "true" ]; then + if [ "${MOBY_BUILDX_VERSION}" = "latest" ]; then + # Empty, meaning grab whatever "latest" is in apt repo + buildx_version_suffix="" + else + case ${ADJUSTED_ID} in + debian) + buildx_version_dot_escaped="${MOBY_BUILDX_VERSION//./\\.}" + buildx_version_dot_plus_escaped="${buildx_version_dot_escaped//+/\\+}" + buildx_version_regex="^(.+:)?${buildx_version_dot_plus_escaped}([\\.\\+ ~:-]|$)" + set +e + buildx_version_suffix="=$(apt-cache madison moby-buildx | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${buildx_version_regex}")" + set -e + if [ -z "${buildx_version_suffix}" ] || [ "${buildx_version_suffix}" = "=" ]; then + err "No full or partial moby-buildx version match found for \"${MOBY_BUILDX_VERSION}\" on OS ${ID} ${VERSION_CODENAME} (${architecture}). Available versions:" + apt-cache madison moby-buildx | awk -F"|" '{print $2}' | grep -oP '^(.+:)?\K.+' + exit 1 + fi + ;; + rhel) + # For RHEL-based systems, try to find buildx version or use latest + buildx_version_escaped="${MOBY_BUILDX_VERSION//./\\.}" + set +e + available_buildx=$(${PKG_MGR_CMD} list --available moby-buildx 2>/dev/null | grep -v "Available Packages" | awk '{print $2}' | grep -E "^${buildx_version_escaped}" | head -1) + set -e + if [ -n "${available_buildx}" ]; then + buildx_version_suffix="-${available_buildx}" + else + echo "(*) Exact buildx version ${MOBY_BUILDX_VERSION} not found, using latest available" + buildx_version_suffix="" + fi + ;; + esac + echo "buildx_version_suffix ${buildx_version_suffix}" + fi + fi + + # Install Docker / Moby CLI if not already installed + if type docker > /dev/null 2>&1 && type dockerd > /dev/null 2>&1; then + echo "Docker / Moby CLI and Engine already installed." + else + case ${ADJUSTED_ID} in + debian) + if [ "${USE_MOBY}" = "true" ]; then + # Install engine + set +e # Handle error gracefully + apt-get -y install --no-install-recommends moby-cli${cli_version_suffix} moby-buildx${buildx_version_suffix} moby-engine${engine_version_suffix} + exit_code=$? + set -e + + if [ ${exit_code} -ne 0 ]; then + err "Packages for moby not available in OS ${ID} ${VERSION_CODENAME} (${architecture}). To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS version (eg: 'ubuntu-24.04')." + exit 1 + fi + + # Install compose + apt-get -y install --no-install-recommends moby-compose || err "Package moby-compose (Docker Compose v2) not available for OS ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." + else + apt-get -y install --no-install-recommends docker-ce-cli${cli_version_suffix} docker-ce${engine_version_suffix} + # Install compose + apt-mark hold docker-ce docker-ce-cli + apt-get -y install --no-install-recommends docker-compose-plugin || echo "(*) Package docker-compose-plugin (Docker Compose v2) not available for OS ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." + fi + ;; + rhel) + if [ "${USE_MOBY}" = "true" ]; then + set +e # Handle error gracefully + ${PKG_MGR_CMD} -y install moby-cli${cli_version_suffix} moby-engine${engine_version_suffix} + exit_code=$? + set -e + + if [ ${exit_code} -ne 0 ]; then + err "Packages for moby not available in OS ${ID} ${VERSION_CODENAME} (${architecture}). To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS version." + exit 1 + fi + + # Install compose + if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "none" ]; then + ${PKG_MGR_CMD} -y install moby-compose || echo "(*) Package moby-compose not available for ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." + fi + else + # Special handling for Azure Linux Docker CE installation + if [ "${ID}" = "azurelinux" ] || [ "${ID}" = "mariner" ]; then + echo "(*) Installing Docker CE on Azure Linux (bypassing container-selinux dependency)..." + + # Use rpm with --force and --nodeps for Azure Linux + set +e # Don't exit on error for this section + ${PKG_MGR_CMD} -y install docker-ce${cli_version_suffix} docker-ce-cli${engine_version_suffix} containerd.io + install_result=$? + set -e + + if [ $install_result -ne 0 ]; then + echo "(*) Standard installation failed, trying manual installation..." + + echo "(*) Standard installation failed, trying manual installation..." + + # Create directory for downloading packages + mkdir -p /tmp/docker-ce-install + + # Download packages manually using curl since tdnf doesn't support download + echo "(*) Downloading Docker CE packages manually..." + + # Get the repository baseurl + repo_baseurl="https://download.docker.com/linux/centos/9/x86_64/stable" + + # Download packages directly + cd /tmp/docker-ce-install + + # Get package names with versions + if [ -n "${cli_version_suffix}" ]; then + docker_ce_version="${cli_version_suffix#-}" + docker_cli_version="${engine_version_suffix#-}" + else + # Get latest version from repository + docker_ce_version="latest" + fi + + echo "(*) Attempting to download Docker CE packages from repository..." + + # Try to download latest packages if specific version fails + if ! curl -fsSL "${repo_baseurl}/Packages/docker-ce-${docker_ce_version}.el9.x86_64.rpm" -o docker-ce.rpm 2>/dev/null; then + # Fallback: try to get latest available version + echo "(*) Specific version not found, trying latest..." + latest_docker=$(curl -s "${repo_baseurl}/Packages/" | grep -o 'docker-ce-[0-9][^"]*\.el9\.x86_64\.rpm' | head -1) + latest_cli=$(curl -s "${repo_baseurl}/Packages/" | grep -o 'docker-ce-cli-[0-9][^"]*\.el9\.x86_64\.rpm' | head -1) + latest_containerd=$(curl -s "${repo_baseurl}/Packages/" | grep -o 'containerd\.io-[0-9][^"]*\.el9\.x86_64\.rpm' | head -1) + + if [ -n "${latest_docker}" ]; then + curl -fsSL "${repo_baseurl}/Packages/${latest_docker}" -o docker-ce.rpm + curl -fsSL "${repo_baseurl}/Packages/${latest_cli}" -o docker-ce-cli.rpm + curl -fsSL "${repo_baseurl}/Packages/${latest_containerd}" -o containerd.io.rpm + else + echo "(*) ERROR: Could not find Docker CE packages in repository" + echo "(*) Please check repository configuration or use 'moby': true" + exit 1 + fi + fi + # Install systemd libraries required by Docker CE + echo "(*) Installing systemd libraries required by Docker CE..." + ${PKG_MGR_CMD} -y install systemd-libs || ${PKG_MGR_CMD} -y install systemd-devel || { + echo "(*) WARNING: Could not install systemd libraries" + echo "(*) Docker may fail to start without these" + } + + # Install with rpm --force --nodeps + echo "(*) Installing Docker CE packages with dependency override..." + rpm -Uvh --force --nodeps *.rpm + + # Cleanup + cd / + rm -rf /tmp/docker-ce-install + + echo "(*) Docker CE installation completed with dependency bypass" + echo "(*) Note: Some SELinux functionality may be limited without container-selinux" + fi + else + # Standard installation for other RHEL-based systems + ${PKG_MGR_CMD} -y install docker-ce${cli_version_suffix} docker-ce-cli${engine_version_suffix} containerd.io + fi + # Install compose + if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "none" ]; then + ${PKG_MGR_CMD} -y install docker-compose-plugin || echo "(*) Package docker-compose-plugin not available for ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." + fi + fi + ;; + esac + fi + + echo "Finished installing docker / moby!" + + docker_home="/usr/libexec/docker" + cli_plugins_dir="${docker_home}/cli-plugins" + + # fallback for docker-compose + fallback_compose(){ + local url=$1 + local repo_url=$(get_github_api_repo_url "$url") + echo -e "\n(!) Failed to fetch the latest artifacts for docker-compose v${compose_version}..." + get_previous_version "${url}" "${repo_url}" compose_version + echo -e "\nAttempting to install v${compose_version}" + curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path} + } + + # If 'docker-compose' command is to be included + if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "none" ]; then + case "${architecture}" in + amd64|x86_64) target_compose_arch=x86_64 ;; + arm64|aarch64) target_compose_arch=aarch64 ;; + *) + echo "(!) Docker in docker does not support machine architecture '$architecture'. Please use an x86-64 or ARM64 machine." + exit 1 + esac + + docker_compose_path="/usr/local/bin/docker-compose" + if [ "${DOCKER_DASH_COMPOSE_VERSION}" = "v1" ]; then + err "The final Compose V1 release, version 1.29.2, was May 10, 2021. These packages haven't received any security updates since then. Use at your own risk." + INSTALL_DOCKER_COMPOSE_SWITCH="false" + + if [ "${target_compose_arch}" = "x86_64" ]; then + echo "(*) Installing docker compose v1..." + curl -fsSL "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-Linux-x86_64" -o ${docker_compose_path} + chmod +x ${docker_compose_path} + + # Download the SHA256 checksum + DOCKER_COMPOSE_SHA256="$(curl -sSL "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-Linux-x86_64.sha256" | awk '{print $1}')" + echo "${DOCKER_COMPOSE_SHA256} ${docker_compose_path}" > docker-compose.sha256sum + sha256sum -c docker-compose.sha256sum --ignore-missing + elif [ "${VERSION_CODENAME}" = "bookworm" ]; then + err "Docker compose v1 is unavailable for 'bookworm' on Arm64. Kindly switch to use v2" + exit 1 + else + # Use pip to get a version that runs on this architecture + check_packages python3-minimal python3-pip libffi-dev python3-venv + echo "(*) Installing docker compose v1 via pip..." + export PYTHONUSERBASE=/usr/local + pip3 install --disable-pip-version-check --no-cache-dir --user "Cython<3.0" pyyaml wheel docker-compose --no-build-isolation + fi + else + compose_version=${DOCKER_DASH_COMPOSE_VERSION#v} + docker_compose_url="https://github.com/docker/compose" + find_version_from_git_tags compose_version "$docker_compose_url" "tags/v" + echo "(*) Installing docker-compose ${compose_version}..." + curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path} || { + echo -e "\n(!) Failed to fetch the latest artifacts for docker-compose v${compose_version}..." + fallback_compose "$docker_compose_url" + } + + chmod +x ${docker_compose_path} + + # Download the SHA256 checksum + DOCKER_COMPOSE_SHA256="$(curl -sSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}.sha256" | awk '{print $1}')" + echo "${DOCKER_COMPOSE_SHA256} ${docker_compose_path}" > docker-compose.sha256sum + sha256sum -c docker-compose.sha256sum --ignore-missing + + mkdir -p ${cli_plugins_dir} + cp ${docker_compose_path} ${cli_plugins_dir} + fi + fi + + # fallback method for compose-switch + fallback_compose-switch() { + local url=$1 + local repo_url=$(get_github_api_repo_url "$url") + echo -e "\n(!) Failed to fetch the latest artifacts for compose-switch v${compose_switch_version}..." + get_previous_version "$url" "$repo_url" compose_switch_version + echo -e "\nAttempting to install v${compose_switch_version}" + curl -fsSL "https://github.com/docker/compose-switch/releases/download/v${compose_switch_version}/docker-compose-linux-${target_switch_arch}" -o /usr/local/bin/compose-switch + } + # Install docker-compose switch if not already installed - https://github.com/docker/compose-switch#manual-installation + if [ "${INSTALL_DOCKER_COMPOSE_SWITCH}" = "true" ] && ! type compose-switch > /dev/null 2>&1; then + if type docker-compose > /dev/null 2>&1; then + echo "(*) Installing compose-switch..." + current_compose_path="$(command -v docker-compose)" + target_compose_path="$(dirname "${current_compose_path}")/docker-compose-v1" + compose_switch_version="latest" + compose_switch_url="https://github.com/docker/compose-switch" + # Try to get latest version, fallback to known stable version if GitHub API fails + set +e + find_version_from_git_tags compose_switch_version "$compose_switch_url" + if [ $? -ne 0 ] || [ -z "${compose_switch_version}" ] || [ "${compose_switch_version}" = "latest" ]; then + echo "(*) GitHub API rate limited or failed, using fallback method" + fallback_compose-switch "$compose_switch_url" + fi + set -e + + # Map architecture for compose-switch downloads + case "${architecture}" in + amd64|x86_64) target_switch_arch=amd64 ;; + arm64|aarch64) target_switch_arch=arm64 ;; + *) target_switch_arch=${architecture} ;; + esac + curl -fsSL "https://github.com/docker/compose-switch/releases/download/v${compose_switch_version}/docker-compose-linux-${target_switch_arch}" -o /usr/local/bin/compose-switch || fallback_compose-switch "$compose_switch_url" + chmod +x /usr/local/bin/compose-switch + # TODO: Verify checksum once available: https://github.com/docker/compose-switch/issues/11 + # Setup v1 CLI as alternative in addition to compose-switch (which maps to v2) + mv "${current_compose_path}" "${target_compose_path}" + update-alternatives --install ${docker_compose_path} docker-compose /usr/local/bin/compose-switch 99 + update-alternatives --install ${docker_compose_path} docker-compose "${target_compose_path}" 1 + else + err "Skipping installation of compose-switch as docker compose is unavailable..." + fi + fi + + # If init file already exists, exit + if [ -f "/usr/local/share/docker-init.sh" ]; then + echo "/usr/local/share/docker-init.sh already exists, so exiting." + # Clean up + rm -rf /var/lib/apt/lists/* + exit 0 + fi + echo "docker-init doesn't exist, adding..." + + if ! cat /etc/group | grep -e "^docker:" > /dev/null 2>&1; then + groupadd -r docker + fi + + usermod -aG docker ${USERNAME} + + # fallback for docker/buildx + fallback_buildx() { + local url=$1 + local repo_url=$(get_github_api_repo_url "$url") + echo -e "\n(!) Failed to fetch the latest artifacts for docker buildx v${buildx_version}..." + get_previous_version "$url" "$repo_url" buildx_version + buildx_file_name="buildx-v${buildx_version}.linux-${target_buildx_arch}" + echo -e "\nAttempting to install v${buildx_version}" + wget https://github.com/docker/buildx/releases/download/v${buildx_version}/${buildx_file_name} + } + + if [ "${INSTALL_DOCKER_BUILDX}" = "true" ]; then + buildx_version="latest" + docker_buildx_url="https://github.com/docker/buildx" + find_version_from_git_tags buildx_version "$docker_buildx_url" "refs/tags/v" + echo "(*) Installing buildx ${buildx_version}..." + + # Map architecture for buildx downloads + case "${architecture}" in + amd64|x86_64) target_buildx_arch=amd64 ;; + arm64|aarch64) target_buildx_arch=arm64 ;; + *) target_buildx_arch=${architecture} ;; + esac + + buildx_file_name="buildx-v${buildx_version}.linux-${target_buildx_arch}" + + cd /tmp + wget https://github.com/docker/buildx/releases/download/v${buildx_version}/${buildx_file_name} || fallback_buildx "$docker_buildx_url" + + docker_home="/usr/libexec/docker" + cli_plugins_dir="${docker_home}/cli-plugins" + + mkdir -p ${cli_plugins_dir} + mv ${buildx_file_name} ${cli_plugins_dir}/docker-buildx + chmod +x ${cli_plugins_dir}/docker-buildx + + chown -R "${USERNAME}:docker" "${docker_home}" + chmod -R g+r+w "${docker_home}" + find "${docker_home}" -type d -print0 | xargs -n 1 -0 chmod g+s + fi + + DOCKER_DEFAULT_IP6_TABLES="" + if [ "$DISABLE_IP6_TABLES" == true ]; then + requested_version="" + # checking whether the version requested either is in semver format or just a number denoting the major version + # and, extracting the major version number out of the two scenarios + semver_regex="^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$" + if echo "$DOCKER_VERSION" | grep -Eq $semver_regex; then + requested_version=$(echo $DOCKER_VERSION | cut -d. -f1) + elif echo "$DOCKER_VERSION" | grep -Eq "^[1-9][0-9]*$"; then + requested_version=$DOCKER_VERSION + fi + if [ "$DOCKER_VERSION" = "latest" ] || [[ -n "$requested_version" && "$requested_version" -ge 27 ]] ; then + DOCKER_DEFAULT_IP6_TABLES="--ip6tables=false" + echo "(!) As requested, passing '${DOCKER_DEFAULT_IP6_TABLES}'" + fi + fi + + if [ ! -d /usr/local/share ]; then + mkdir -p /usr/local/share + fi + + tee /usr/local/share/docker-init.sh > /dev/null \ + << EOF + #!/bin/sh + #------------------------------------------------------------------------------------------------------------- + # Copyright (c) Microsoft Corporation. All rights reserved. + # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. + #------------------------------------------------------------------------------------------------------------- + + set -e + + AZURE_DNS_AUTO_DETECTION=${AZURE_DNS_AUTO_DETECTION} + DOCKER_DEFAULT_ADDRESS_POOL=${DOCKER_DEFAULT_ADDRESS_POOL} + DOCKER_DEFAULT_IP6_TABLES=${DOCKER_DEFAULT_IP6_TABLES} + EOF + + tee -a /usr/local/share/docker-init.sh > /dev/null \ + << 'EOF' + dockerd_start="AZURE_DNS_AUTO_DETECTION=${AZURE_DNS_AUTO_DETECTION} DOCKER_DEFAULT_ADDRESS_POOL=${DOCKER_DEFAULT_ADDRESS_POOL} DOCKER_DEFAULT_IP6_TABLES=${DOCKER_DEFAULT_IP6_TABLES} $(cat << 'INNEREOF' + # explicitly remove dockerd and containerd PID file to ensure that it can start properly if it was stopped uncleanly + find /run /var/run -iname 'docker*.pid' -delete || : + find /run /var/run -iname 'container*.pid' -delete || : + + # -- Start: dind wrapper script -- + # Maintained: https://github.com/moby/moby/blob/master/hack/dind + + export container=docker + + if [ -d /sys/kernel/security ] && ! mountpoint -q /sys/kernel/security; then + mount -t securityfs none /sys/kernel/security || { + echo >&2 'Could not mount /sys/kernel/security.' + echo >&2 'AppArmor detection and --privileged mode might break.' + } + fi + + # Mount /tmp (conditionally) + if ! mountpoint -q /tmp; then + mount -t tmpfs none /tmp + fi + + set_cgroup_nesting() + { + # cgroup v2: enable nesting + if [ -f /sys/fs/cgroup/cgroup.controllers ]; then + # move the processes from the root group to the /init group, + # otherwise writing subtree_control fails with EBUSY. + # An error during moving non-existent process (i.e., "cat") is ignored. + mkdir -p /sys/fs/cgroup/init + xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs || : + # enable controllers + sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers \ + > /sys/fs/cgroup/cgroup.subtree_control + fi + } + + # Set cgroup nesting, retrying if necessary + retry_cgroup_nesting=0 + + until [ "${retry_cgroup_nesting}" -eq "5" ]; + do + set +e + set_cgroup_nesting + + if [ $? -ne 0 ]; then + echo "(*) cgroup v2: Failed to enable nesting, retrying..." + else + break + fi + + retry_cgroup_nesting=`expr $retry_cgroup_nesting + 1` + set -e + done + + # -- End: dind wrapper script -- + + # Handle DNS + set +e + cat /etc/resolv.conf | grep -i 'internal.cloudapp.net' > /dev/null 2>&1 + if [ $? -eq 0 ] && [ "${AZURE_DNS_AUTO_DETECTION}" = "true" ] + then + echo "Setting dockerd Azure DNS." + CUSTOMDNS="--dns 168.63.129.16" + else + echo "Not setting dockerd DNS manually." + CUSTOMDNS="" + fi + set -e + + if [ -z "$DOCKER_DEFAULT_ADDRESS_POOL" ] + then + DEFAULT_ADDRESS_POOL="" + else + DEFAULT_ADDRESS_POOL="--default-address-pool $DOCKER_DEFAULT_ADDRESS_POOL" + fi + + # Start docker/moby engine + ( dockerd $CUSTOMDNS $DEFAULT_ADDRESS_POOL $DOCKER_DEFAULT_IP6_TABLES > /tmp/dockerd.log 2>&1 ) & + INNEREOF + )" + + sudo_if() { + COMMAND="$*" + + if [ "$(id -u)" -ne 0 ]; then + sudo $COMMAND + else + $COMMAND + fi + } + + retry_docker_start_count=0 + docker_ok="false" + + until [ "${docker_ok}" = "true" ] || [ "${retry_docker_start_count}" -eq "5" ]; + do + # Start using sudo if not invoked as root + if [ "$(id -u)" -ne 0 ]; then + sudo /bin/sh -c "${dockerd_start}" + else + eval "${dockerd_start}" + fi + + retry_count=0 + until [ "${docker_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; + do + sleep 1s + set +e + docker info > /dev/null 2>&1 && docker_ok="true" + set -e + + retry_count=`expr $retry_count + 1` + done + + if [ "${docker_ok}" != "true" ] && [ "${retry_docker_start_count}" != "4" ]; then + echo "(*) Failed to start docker, retrying..." + set +e + sudo_if pkill dockerd + sudo_if pkill containerd + set -e + fi + + retry_docker_start_count=`expr $retry_docker_start_count + 1` + done + + # Execute whatever commands were passed in (if any). This allows us + # to set this script to ENTRYPOINT while still executing the default CMD. + exec "$@" + EOF + + chmod +x /usr/local/share/docker-init.sh + chown ${USERNAME}:root /usr/local/share/docker-init.sh + + # Clean up + rm -rf /var/lib/apt/lists/* + + echo 'docker-in-docker-debian script has completed!'"#), + ]).await; + + return Ok(http::Response::builder() + .status(200) + .body(AsyncBody::from(response)) + .unwrap()); + } + if parts.uri.path() == "/v2/devcontainers/features/go/manifests/1" { + let response = r#" + { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.devcontainers", + "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "size": 2 + }, + "layers": [ + { + "mediaType": "application/vnd.devcontainers.layer.v1+tar", + "digest": "sha256:eadd8a4757ee8ea6c1bc0aae22da49b7e5f2f1e32a87a5eac3cadeb7d2ccdad1", + "size": 20992, + "annotations": { + "org.opencontainers.image.title": "devcontainer-feature-go.tgz" + } + } + ], + "annotations": { + "dev.containers.metadata": "{\"id\":\"go\",\"version\":\"1.3.3\",\"name\":\"Go\",\"documentationURL\":\"https://github.com/devcontainers/features/tree/main/src/go\",\"description\":\"Installs Go and common Go utilities. Auto-detects latest version and installs needed dependencies.\",\"options\":{\"version\":{\"type\":\"string\",\"proposals\":[\"latest\",\"none\",\"1.24\",\"1.23\"],\"default\":\"latest\",\"description\":\"Select or enter a Go version to install\"},\"golangciLintVersion\":{\"type\":\"string\",\"default\":\"latest\",\"description\":\"Version of golangci-lint to install\"}},\"init\":true,\"customizations\":{\"vscode\":{\"extensions\":[\"golang.Go\"],\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes Go and common Go utilities pre-installed and available on the `PATH`, along with the Go language extension for Go development.\"}]}}},\"containerEnv\":{\"GOROOT\":\"/usr/local/go\",\"GOPATH\":\"/go\",\"PATH\":\"/usr/local/go/bin:/go/bin:${PATH}\"},\"capAdd\":[\"SYS_PTRACE\"],\"securityOpt\":[\"seccomp=unconfined\"],\"installsAfter\":[\"ghcr.io/devcontainers/features/common-utils\"]}", + "com.github.package.type": "devcontainer_feature" + } + } + "#; + + return Ok(http::Response::builder() + .status(200) + .body(http_client::AsyncBody::from(response)) + .unwrap()); + } + if parts.uri.path() + == "/v2/devcontainers/features/go/blobs/sha256:eadd8a4757ee8ea6c1bc0aae22da49b7e5f2f1e32a87a5eac3cadeb7d2ccdad1" + { + let response = build_tarball(vec![ + ("./devcontainer-feature.json", r#" + { + "id": "go", + "version": "1.3.3", + "name": "Go", + "documentationURL": "https://github.com/devcontainers/features/tree/main/src/go", + "description": "Installs Go and common Go utilities. Auto-detects latest version and installs needed dependencies.", + "options": { + "version": { + "type": "string", + "proposals": [ + "latest", + "none", + "1.24", + "1.23" + ], + "default": "latest", + "description": "Select or enter a Go version to install" + }, + "golangciLintVersion": { + "type": "string", + "default": "latest", + "description": "Version of golangci-lint to install" + } + }, + "init": true, + "customizations": { + "vscode": { + "extensions": [ + "golang.Go" + ], + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes Go and common Go utilities pre-installed and available on the `PATH`, along with the Go language extension for Go development." + } + ] + } + } + }, + "containerEnv": { + "GOROOT": "/usr/local/go", + "GOPATH": "/go", + "PATH": "/usr/local/go/bin:/go/bin:${PATH}" + }, + "capAdd": [ + "SYS_PTRACE" + ], + "securityOpt": [ + "seccomp=unconfined" + ], + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] + } + "#), + ("./install.sh", r#" + #!/usr/bin/env bash + #------------------------------------------------------------------------------------------------------------- + # Copyright (c) Microsoft Corporation. All rights reserved. + # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information + #------------------------------------------------------------------------------------------------------------- + # + # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/go.md + # Maintainer: The VS Code and Codespaces Teams + + TARGET_GO_VERSION="${VERSION:-"latest"}" + GOLANGCILINT_VERSION="${GOLANGCILINTVERSION:-"latest"}" + + TARGET_GOROOT="${TARGET_GOROOT:-"/usr/local/go"}" + TARGET_GOPATH="${TARGET_GOPATH:-"/go"}" + USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" + INSTALL_GO_TOOLS="${INSTALL_GO_TOOLS:-"true"}" + + # https://www.google.com/linuxrepositories/ + GO_GPG_KEY_URI="https://dl.google.com/linux/linux_signing_key.pub" + + set -e + + if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 + fi + + # Bring in ID, ID_LIKE, VERSION_ID, VERSION_CODENAME + . /etc/os-release + # Get an adjusted ID independent of distro variants + MAJOR_VERSION_ID=$(echo ${VERSION_ID} | cut -d . -f 1) + if [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ]; then + ADJUSTED_ID="debian" + elif [[ "${ID}" = "rhel" || "${ID}" = "fedora" || "${ID}" = "mariner" || "${ID_LIKE}" = *"rhel"* || "${ID_LIKE}" = *"fedora"* || "${ID_LIKE}" = *"mariner"* ]]; then + ADJUSTED_ID="rhel" + if [[ "${ID}" = "rhel" ]] || [[ "${ID}" = *"alma"* ]] || [[ "${ID}" = *"rocky"* ]]; then + VERSION_CODENAME="rhel${MAJOR_VERSION_ID}" + else + VERSION_CODENAME="${ID}${MAJOR_VERSION_ID}" + fi + else + echo "Linux distro ${ID} not supported." + exit 1 + fi + + if [ "${ADJUSTED_ID}" = "rhel" ] && [ "${VERSION_CODENAME-}" = "centos7" ]; then + # As of 1 July 2024, mirrorlist.centos.org no longer exists. + # Update the repo files to reference vault.centos.org. + sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/*.repo + sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/*.repo + sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/*.repo + fi + + # Setup INSTALL_CMD & PKG_MGR_CMD + if type apt-get > /dev/null 2>&1; then + PKG_MGR_CMD=apt-get + INSTALL_CMD="${PKG_MGR_CMD} -y install --no-install-recommends" + elif type microdnf > /dev/null 2>&1; then + PKG_MGR_CMD=microdnf + INSTALL_CMD="${PKG_MGR_CMD} ${INSTALL_CMD_ADDL_REPOS} -y install --refresh --best --nodocs --noplugins --setopt=install_weak_deps=0" + elif type dnf > /dev/null 2>&1; then + PKG_MGR_CMD=dnf + INSTALL_CMD="${PKG_MGR_CMD} ${INSTALL_CMD_ADDL_REPOS} -y install --refresh --best --nodocs --noplugins --setopt=install_weak_deps=0" + else + PKG_MGR_CMD=yum + INSTALL_CMD="${PKG_MGR_CMD} ${INSTALL_CMD_ADDL_REPOS} -y install --noplugins --setopt=install_weak_deps=0" + fi + + # Clean up + clean_up() { + case ${ADJUSTED_ID} in + debian) + rm -rf /var/lib/apt/lists/* + ;; + rhel) + rm -rf /var/cache/dnf/* /var/cache/yum/* + rm -rf /tmp/yum.log + rm -rf ${GPG_INSTALL_PATH} + ;; + esac + } + clean_up + + + # Figure out correct version of a three part version number is not passed + find_version_from_git_tags() { + local variable_name=$1 + local requested_version=${!variable_name} + if [ "${requested_version}" = "none" ]; then return; fi + local repository=$2 + local prefix=${3:-"tags/v"} + local separator=${4:-"."} + local last_part_optional=${5:-"false"} + if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then + local escaped_separator=${separator//./\\.} + local last_part + if [ "${last_part_optional}" = "true" ]; then + last_part="(${escaped_separator}[0-9]+)?" + else + last_part="${escaped_separator}[0-9]+" + fi + local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" + local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" + if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then + declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" + else + set +e + declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" + set -e + fi + fi + if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then + echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 + exit 1 + fi + echo "${variable_name}=${!variable_name}" + } + + pkg_mgr_update() { + case $ADJUSTED_ID in + debian) + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + ${PKG_MGR_CMD} update -y + fi + ;; + rhel) + if [ ${PKG_MGR_CMD} = "microdnf" ]; then + if [ "$(ls /var/cache/yum/* 2>/dev/null | wc -l)" = 0 ]; then + echo "Running ${PKG_MGR_CMD} makecache ..." + ${PKG_MGR_CMD} makecache + fi + else + if [ "$(ls /var/cache/${PKG_MGR_CMD}/* 2>/dev/null | wc -l)" = 0 ]; then + echo "Running ${PKG_MGR_CMD} check-update ..." + set +e + ${PKG_MGR_CMD} check-update + rc=$? + if [ $rc != 0 ] && [ $rc != 100 ]; then + exit 1 + fi + set -e + fi + fi + ;; + esac + } + + # Checks if packages are installed and installs them if not + check_packages() { + case ${ADJUSTED_ID} in + debian) + if ! dpkg -s "$@" > /dev/null 2>&1; then + pkg_mgr_update + ${INSTALL_CMD} "$@" + fi + ;; + rhel) + if ! rpm -q "$@" > /dev/null 2>&1; then + pkg_mgr_update + ${INSTALL_CMD} "$@" + fi + ;; + esac + } + + # Ensure that login shells get the correct path if the user updated the PATH using ENV. + rm -f /etc/profile.d/00-restore-env.sh + echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh + chmod +x /etc/profile.d/00-restore-env.sh + + # Some distributions do not install awk by default (e.g. Mariner) + if ! type awk >/dev/null 2>&1; then + check_packages awk + fi + + # Determine the appropriate non-root user + if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + USERNAME="" + POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do + if id -u ${CURRENT_USER} > /dev/null 2>&1; then + USERNAME=${CURRENT_USER} + break + fi + done + if [ "${USERNAME}" = "" ]; then + USERNAME=root + fi + elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then + USERNAME=root + fi + + export DEBIAN_FRONTEND=noninteractive + + check_packages ca-certificates gnupg2 tar gcc make pkg-config + + if [ $ADJUSTED_ID = "debian" ]; then + check_packages g++ libc6-dev + else + check_packages gcc-c++ glibc-devel + fi + # Install curl, git, other dependencies if missing + if ! type curl > /dev/null 2>&1; then + check_packages curl + fi + if ! type git > /dev/null 2>&1; then + check_packages git + fi + # Some systems, e.g. Mariner, still a few more packages + if ! type as > /dev/null 2>&1; then + check_packages binutils + fi + if ! [ -f /usr/include/linux/errno.h ]; then + check_packages kernel-headers + fi + # Minimal RHEL install may need findutils installed + if ! [ -f /usr/bin/find ]; then + check_packages findutils + fi + + # Get closest match for version number specified + find_version_from_git_tags TARGET_GO_VERSION "https://go.googlesource.com/go" "tags/go" "." "true" + + architecture="$(uname -m)" + case $architecture in + x86_64) architecture="amd64";; + aarch64 | armv8*) architecture="arm64";; + aarch32 | armv7* | armvhf*) architecture="armv6l";; + i?86) architecture="386";; + *) echo "(!) Architecture $architecture unsupported"; exit 1 ;; + esac + + # Install Go + umask 0002 + if ! cat /etc/group | grep -e "^golang:" > /dev/null 2>&1; then + groupadd -r golang + fi + usermod -a -G golang "${USERNAME}" + mkdir -p "${TARGET_GOROOT}" "${TARGET_GOPATH}" + + if [[ "${TARGET_GO_VERSION}" != "none" ]] && [[ "$(go version 2>/dev/null)" != *"${TARGET_GO_VERSION}"* ]]; then + # Use a temporary location for gpg keys to avoid polluting image + export GNUPGHOME="/tmp/tmp-gnupg" + mkdir -p ${GNUPGHOME} + chmod 700 ${GNUPGHOME} + curl -sSL -o /tmp/tmp-gnupg/golang_key "${GO_GPG_KEY_URI}" + gpg -q --import /tmp/tmp-gnupg/golang_key + echo "Downloading Go ${TARGET_GO_VERSION}..." + set +e + curl -fsSL -o /tmp/go.tar.gz "https://golang.org/dl/go${TARGET_GO_VERSION}.linux-${architecture}.tar.gz" + exit_code=$? + set -e + if [ "$exit_code" != "0" ]; then + echo "(!) Download failed." + # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. + set +e + major="$(echo "${TARGET_GO_VERSION}" | grep -oE '^[0-9]+' || echo '')" + minor="$(echo "${TARGET_GO_VERSION}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" + breakfix="$(echo "${TARGET_GO_VERSION}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" + # Handle Go's odd version pattern where "0" releases omit the last part + if [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then + ((minor=minor-1)) + TARGET_GO_VERSION="${major}.${minor}" + # Look for latest version from previous minor release + find_version_from_git_tags TARGET_GO_VERSION "https://go.googlesource.com/go" "tags/go" "." "true" + else + ((breakfix=breakfix-1)) + if [ "${breakfix}" = "0" ]; then + TARGET_GO_VERSION="${major}.${minor}" + else + TARGET_GO_VERSION="${major}.${minor}.${breakfix}" + fi + fi + set -e + echo "Trying ${TARGET_GO_VERSION}..." + curl -fsSL -o /tmp/go.tar.gz "https://golang.org/dl/go${TARGET_GO_VERSION}.linux-${architecture}.tar.gz" + fi + curl -fsSL -o /tmp/go.tar.gz.asc "https://golang.org/dl/go${TARGET_GO_VERSION}.linux-${architecture}.tar.gz.asc" + gpg --verify /tmp/go.tar.gz.asc /tmp/go.tar.gz + echo "Extracting Go ${TARGET_GO_VERSION}..." + tar -xzf /tmp/go.tar.gz -C "${TARGET_GOROOT}" --strip-components=1 + rm -rf /tmp/go.tar.gz /tmp/go.tar.gz.asc /tmp/tmp-gnupg + else + echo "(!) Go is already installed with version ${TARGET_GO_VERSION}. Skipping." + fi + + # Install Go tools that are isImportant && !replacedByGopls based on + # https://github.com/golang/vscode-go/blob/v0.38.0/src/goToolsInformation.ts + GO_TOOLS="\ + golang.org/x/tools/gopls@latest \ + honnef.co/go/tools/cmd/staticcheck@latest \ + golang.org/x/lint/golint@latest \ + github.com/mgechev/revive@latest \ + github.com/go-delve/delve/cmd/dlv@latest \ + github.com/fatih/gomodifytags@latest \ + github.com/haya14busa/goplay/cmd/goplay@latest \ + github.com/cweill/gotests/gotests@latest \ + github.com/josharian/impl@latest" + + if [ "${INSTALL_GO_TOOLS}" = "true" ]; then + echo "Installing common Go tools..." + export PATH=${TARGET_GOROOT}/bin:${PATH} + export GOPATH=/tmp/gotools + export GOCACHE="${GOPATH}/cache" + + mkdir -p "${GOPATH}" /usr/local/etc/vscode-dev-containers "${TARGET_GOPATH}/bin" + cd "${GOPATH}" + + # Use go get for versions of go under 1.16 + go_install_command=install + if [[ "1.16" > "$(go version | grep -oP 'go\K[0-9]+\.[0-9]+(\.[0-9]+)?')" ]]; then + export GO111MODULE=on + go_install_command=get + echo "Go version < 1.16, using go get." + fi + + (echo "${GO_TOOLS}" | xargs -n 1 go ${go_install_command} -v )2>&1 | tee -a /usr/local/etc/vscode-dev-containers/go.log + + # Move Go tools into path + if [ -d "${GOPATH}/bin" ]; then + mv "${GOPATH}/bin"/* "${TARGET_GOPATH}/bin/" + fi + + # Install golangci-lint from precompiled binaries + if [ "$GOLANGCILINT_VERSION" = "latest" ] || [ "$GOLANGCILINT_VERSION" = "" ]; then + echo "Installing golangci-lint latest..." + curl -fsSL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \ + sh -s -- -b "${TARGET_GOPATH}/bin" + else + echo "Installing golangci-lint ${GOLANGCILINT_VERSION}..." + curl -fsSL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \ + sh -s -- -b "${TARGET_GOPATH}/bin" "v${GOLANGCILINT_VERSION}" + fi + + # Remove Go tools temp directory + rm -rf "${GOPATH}" + fi + + + chown -R "${USERNAME}:golang" "${TARGET_GOROOT}" "${TARGET_GOPATH}" + chmod -R g+r+w "${TARGET_GOROOT}" "${TARGET_GOPATH}" + find "${TARGET_GOROOT}" -type d -print0 | xargs -n 1 -0 chmod g+s + find "${TARGET_GOPATH}" -type d -print0 | xargs -n 1 -0 chmod g+s + + # Clean up + clean_up + + echo "Done!" + "#), + ]) + .await; + return Ok(http::Response::builder() + .status(200) + .body(AsyncBody::from(response)) + .unwrap()); + } + if parts.uri.path() == "/v2/devcontainers/features/aws-cli/manifests/1" { + let response = r#" + { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.devcontainers", + "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "size": 2 + }, + "layers": [ + { + "mediaType": "application/vnd.devcontainers.layer.v1+tar", + "digest": "sha256:4e9b04b394fb63e297b3d5f58185406ea45bddb639c2ba83b5a8394643cd5b13", + "size": 19968, + "annotations": { + "org.opencontainers.image.title": "devcontainer-feature-aws-cli.tgz" + } + } + ], + "annotations": { + "dev.containers.metadata": "{\"id\":\"aws-cli\",\"version\":\"1.1.3\",\"name\":\"AWS CLI\",\"documentationURL\":\"https://github.com/devcontainers/features/tree/main/src/aws-cli\",\"description\":\"Installs the AWS CLI along with needed dependencies. Useful for base Dockerfiles that often are missing required install dependencies like gpg.\",\"options\":{\"version\":{\"type\":\"string\",\"proposals\":[\"latest\"],\"default\":\"latest\",\"description\":\"Select or enter an AWS CLI version.\"},\"verbose\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Suppress verbose output.\"}},\"customizations\":{\"vscode\":{\"extensions\":[\"AmazonWebServices.aws-toolkit-vscode\"],\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes the AWS CLI along with needed dependencies pre-installed and available on the `PATH`, along with the AWS Toolkit extensions for AWS development.\"}]}}},\"installsAfter\":[\"ghcr.io/devcontainers/features/common-utils\"]}", + "com.github.package.type": "devcontainer_feature" + } + }"#; + return Ok(http::Response::builder() + .status(200) + .body(AsyncBody::from(response)) + .unwrap()); + } + if parts.uri.path() + == "/v2/devcontainers/features/aws-cli/blobs/sha256:4e9b04b394fb63e297b3d5f58185406ea45bddb639c2ba83b5a8394643cd5b13" + { + let response = build_tarball(vec![ + ( + "./devcontainer-feature.json", + r#" +{ + "id": "aws-cli", + "version": "1.1.3", + "name": "AWS CLI", + "documentationURL": "https://github.com/devcontainers/features/tree/main/src/aws-cli", + "description": "Installs the AWS CLI along with needed dependencies. Useful for base Dockerfiles that often are missing required install dependencies like gpg.", + "options": { + "version": { + "type": "string", + "proposals": [ + "latest" + ], + "default": "latest", + "description": "Select or enter an AWS CLI version." + }, + "verbose": { + "type": "boolean", + "default": true, + "description": "Suppress verbose output." + } + }, + "customizations": { + "vscode": { + "extensions": [ + "AmazonWebServices.aws-toolkit-vscode" + ], + "settings": { + "github.copilot.chat.codeGeneration.instructions": [ + { + "text": "This dev container includes the AWS CLI along with needed dependencies pre-installed and available on the `PATH`, along with the AWS Toolkit extensions for AWS development." + } + ] + } + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} + "#, + ), + ( + "./install.sh", + r#"#!/usr/bin/env bash + #------------------------------------------------------------------------------------------------------------- + # Copyright (c) Microsoft Corporation. All rights reserved. + # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. + #------------------------------------------------------------------------------------------------------------- + # + # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/awscli.md + # Maintainer: The VS Code and Codespaces Teams + + set -e + + # Clean up + rm -rf /var/lib/apt/lists/* + + VERSION=${VERSION:-"latest"} + VERBOSE=${VERBOSE:-"true"} + + AWSCLI_GPG_KEY=FB5DB77FD5C118B80511ADA8A6310ACC4672475C + AWSCLI_GPG_KEY_MATERIAL="-----BEGIN PGP PUBLIC KEY BLOCK----- + + mQINBF2Cr7UBEADJZHcgusOJl7ENSyumXh85z0TRV0xJorM2B/JL0kHOyigQluUG + ZMLhENaG0bYatdrKP+3H91lvK050pXwnO/R7fB/FSTouki4ciIx5OuLlnJZIxSzx + PqGl0mkxImLNbGWoi6Lto0LYxqHN2iQtzlwTVmq9733zd3XfcXrZ3+LblHAgEt5G + TfNxEKJ8soPLyWmwDH6HWCnjZ/aIQRBTIQ05uVeEoYxSh6wOai7ss/KveoSNBbYz + gbdzoqI2Y8cgH2nbfgp3DSasaLZEdCSsIsK1u05CinE7k2qZ7KgKAUIcT/cR/grk + C6VwsnDU0OUCideXcQ8WeHutqvgZH1JgKDbznoIzeQHJD238GEu+eKhRHcz8/jeG + 94zkcgJOz3KbZGYMiTh277Fvj9zzvZsbMBCedV1BTg3TqgvdX4bdkhf5cH+7NtWO + lrFj6UwAsGukBTAOxC0l/dnSmZhJ7Z1KmEWilro/gOrjtOxqRQutlIqG22TaqoPG + fYVN+en3Zwbt97kcgZDwqbuykNt64oZWc4XKCa3mprEGC3IbJTBFqglXmZ7l9ywG + EEUJYOlb2XrSuPWml39beWdKM8kzr1OjnlOm6+lpTRCBfo0wa9F8YZRhHPAkwKkX + XDeOGpWRj4ohOx0d2GWkyV5xyN14p2tQOCdOODmz80yUTgRpPVQUtOEhXQARAQAB + tCFBV1MgQ0xJIFRlYW0gPGF3cy1jbGlAYW1hem9uLmNvbT6JAlQEEwEIAD4WIQT7 + Xbd/1cEYuAURraimMQrMRnJHXAUCXYKvtQIbAwUJB4TOAAULCQgHAgYVCgkICwIE + FgIDAQIeAQIXgAAKCRCmMQrMRnJHXJIXEAChLUIkg80uPUkGjE3jejvQSA1aWuAM + yzy6fdpdlRUz6M6nmsUhOExjVIvibEJpzK5mhuSZ4lb0vJ2ZUPgCv4zs2nBd7BGJ + MxKiWgBReGvTdqZ0SzyYH4PYCJSE732x/Fw9hfnh1dMTXNcrQXzwOmmFNNegG0Ox + au+VnpcR5Kz3smiTrIwZbRudo1ijhCYPQ7t5CMp9kjC6bObvy1hSIg2xNbMAN/Do + ikebAl36uA6Y/Uczjj3GxZW4ZWeFirMidKbtqvUz2y0UFszobjiBSqZZHCreC34B + hw9bFNpuWC/0SrXgohdsc6vK50pDGdV5kM2qo9tMQ/izsAwTh/d/GzZv8H4lV9eO + tEis+EpR497PaxKKh9tJf0N6Q1YLRHof5xePZtOIlS3gfvsH5hXA3HJ9yIxb8T0H + QYmVr3aIUse20i6meI3fuV36VFupwfrTKaL7VXnsrK2fq5cRvyJLNzXucg0WAjPF + RrAGLzY7nP1xeg1a0aeP+pdsqjqlPJom8OCWc1+6DWbg0jsC74WoesAqgBItODMB + rsal1y/q+bPzpsnWjzHV8+1/EtZmSc8ZUGSJOPkfC7hObnfkl18h+1QtKTjZme4d + H17gsBJr+opwJw/Zio2LMjQBOqlm3K1A4zFTh7wBC7He6KPQea1p2XAMgtvATtNe + YLZATHZKTJyiqA== + =vYOk + -----END PGP PUBLIC KEY BLOCK-----" + + if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 + fi + + apt_get_update() + { + if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update -y + fi + } + + # Checks if packages are installed and installs them if not + check_packages() { + if ! dpkg -s "$@" > /dev/null 2>&1; then + apt_get_update + apt-get -y install --no-install-recommends "$@" + fi + } + + export DEBIAN_FRONTEND=noninteractive + + check_packages curl ca-certificates gpg dirmngr unzip bash-completion less + + verify_aws_cli_gpg_signature() { + local filePath=$1 + local sigFilePath=$2 + local awsGpgKeyring=aws-cli-public-key.gpg + + echo "${AWSCLI_GPG_KEY_MATERIAL}" | gpg --dearmor > "./${awsGpgKeyring}" + gpg --batch --quiet --no-default-keyring --keyring "./${awsGpgKeyring}" --verify "${sigFilePath}" "${filePath}" + local status=$? + + rm "./${awsGpgKeyring}" + + return ${status} + } + + install() { + local scriptZipFile=awscli.zip + local scriptSigFile=awscli.sig + + # See Linux install docs at https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html + if [ "${VERSION}" != "latest" ]; then + local versionStr=-${VERSION} + fi + architecture=$(dpkg --print-architecture) + case "${architecture}" in + amd64) architectureStr=x86_64 ;; + arm64) architectureStr=aarch64 ;; + *) + echo "AWS CLI does not support machine architecture '$architecture'. Please use an x86-64 or ARM64 machine." + exit 1 + esac + local scriptUrl=https://awscli.amazonaws.com/awscli-exe-linux-${architectureStr}${versionStr}.zip + curl "${scriptUrl}" -o "${scriptZipFile}" + curl "${scriptUrl}.sig" -o "${scriptSigFile}" + + verify_aws_cli_gpg_signature "$scriptZipFile" "$scriptSigFile" + if (( $? > 0 )); then + echo "Could not verify GPG signature of AWS CLI install script. Make sure you provided a valid version." + exit 1 + fi + + if [ "${VERBOSE}" = "false" ]; then + unzip -q "${scriptZipFile}" + else + unzip "${scriptZipFile}" + fi + + ./aws/install + + # kubectl bash completion + mkdir -p /etc/bash_completion.d + cp ./scripts/vendor/aws_bash_completer /etc/bash_completion.d/aws + + # kubectl zsh completion + if [ -e "${USERHOME}/.oh-my-zsh" ]; then + mkdir -p "${USERHOME}/.oh-my-zsh/completions" + cp ./scripts/vendor/aws_zsh_completer.sh "${USERHOME}/.oh-my-zsh/completions/_aws" + chown -R "${USERNAME}" "${USERHOME}/.oh-my-zsh" + fi + + rm -rf ./aws + } + + echo "(*) Installing AWS CLI..." + + install + + # Clean up + rm -rf /var/lib/apt/lists/* + + echo "Done!""#, + ), + ("./scripts/", r#""#), + ( + "./scripts/fetch-latest-completer-scripts.sh", + r#" + #!/bin/bash + #------------------------------------------------------------------------------------------------------------- + # Copyright (c) Microsoft Corporation. All rights reserved. + # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. + #------------------------------------------------------------------------------------------------------------- + # + # Docs: https://github.com/devcontainers/features/tree/main/src/aws-cli + # Maintainer: The Dev Container spec maintainers + # + # Run this script to replace aws_bash_completer and aws_zsh_completer.sh with the latest and greatest available version + # + COMPLETER_SCRIPTS=$(dirname "${BASH_SOURCE[0]}") + BASH_COMPLETER_SCRIPT="$COMPLETER_SCRIPTS/vendor/aws_bash_completer" + ZSH_COMPLETER_SCRIPT="$COMPLETER_SCRIPTS/vendor/aws_zsh_completer.sh" + + wget https://raw.githubusercontent.com/aws/aws-cli/v2/bin/aws_bash_completer -O "$BASH_COMPLETER_SCRIPT" + chmod +x "$BASH_COMPLETER_SCRIPT" + + wget https://raw.githubusercontent.com/aws/aws-cli/v2/bin/aws_zsh_completer.sh -O "$ZSH_COMPLETER_SCRIPT" + chmod +x "$ZSH_COMPLETER_SCRIPT" + "#, + ), + ("./scripts/vendor/", r#""#), + ( + "./scripts/vendor/aws_bash_completer", + r#" + # Typically that would be added under one of the following paths: + # - /etc/bash_completion.d + # - /usr/local/etc/bash_completion.d + # - /usr/share/bash-completion/completions + + complete -C aws_completer aws + "#, + ), + ( + "./scripts/vendor/aws_zsh_completer.sh", + r#" + # Source this file to activate auto completion for zsh using the bash + # compatibility helper. Make sure to run `compinit` before, which should be + # given usually. + # + # % source /path/to/zsh_complete.sh + # + # Typically that would be called somewhere in your .zshrc. + # + # Note, the overwrite of _bash_complete() is to export COMP_LINE and COMP_POINT + # That is only required for zsh <= edab1d3dbe61da7efe5f1ac0e40444b2ec9b9570 + # + # https://github.com/zsh-users/zsh/commit/edab1d3dbe61da7efe5f1ac0e40444b2ec9b9570 + # + # zsh releases prior to that version do not export the required env variables! + + autoload -Uz bashcompinit + bashcompinit -i + + _bash_complete() { + local ret=1 + local -a suf matches + local -x COMP_POINT COMP_CWORD + local -a COMP_WORDS COMPREPLY BASH_VERSINFO + local -x COMP_LINE="$words" + local -A savejobstates savejobtexts + + (( COMP_POINT = 1 + ${#${(j. .)words[1,CURRENT]}} + $#QIPREFIX + $#IPREFIX + $#PREFIX )) + (( COMP_CWORD = CURRENT - 1)) + COMP_WORDS=( $words ) + BASH_VERSINFO=( 2 05b 0 1 release ) + + savejobstates=( ${(kv)jobstates} ) + savejobtexts=( ${(kv)jobtexts} ) + + [[ ${argv[${argv[(I)nospace]:-0}-1]} = -o ]] && suf=( -S '' ) + + matches=( ${(f)"$(compgen $@ -- ${words[CURRENT]})"} ) + + if [[ -n $matches ]]; then + if [[ ${argv[${argv[(I)filenames]:-0}-1]} = -o ]]; then + compset -P '*/' && matches=( ${matches##*/} ) + compset -S '/*' && matches=( ${matches%%/*} ) + compadd -Q -f "${suf[@]}" -a matches && ret=0 + else + compadd -Q "${suf[@]}" -a matches && ret=0 + fi + fi + + if (( ret )); then + if [[ ${argv[${argv[(I)default]:-0}-1]} = -o ]]; then + _default "${suf[@]}" && ret=0 + elif [[ ${argv[${argv[(I)dirnames]:-0}-1]} = -o ]]; then + _directories "${suf[@]}" && ret=0 + fi + fi + + return ret + } + + complete -C aws_completer aws + "#, + ), + ]).await; + + return Ok(http::Response::builder() + .status(200) + .body(AsyncBody::from(response)) + .unwrap()); + } + + Ok(http::Response::builder() + .status(404) + .body(http_client::AsyncBody::default()) + .unwrap()) + }) + } +} diff --git a/crates/dev_container/src/docker.rs b/crates/dev_container/src/docker.rs new file mode 100644 index 0000000000000000000000000000000000000000..9594eae3d0faf67669e7d1ad487925b77a54fc34 --- /dev/null +++ b/crates/dev_container/src/docker.rs @@ -0,0 +1,898 @@ +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>, + pub(crate) state: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)] +pub(crate) struct DockerConfigLabels { + #[serde( + rename = "devcontainer.metadata", + deserialize_with = "deserialize_metadata" + )] + pub(crate) metadata: Option>>, +} + +#[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, + #[serde(default)] + pub(crate) env: Vec, +} + +impl DockerInspectConfig { + pub(crate) fn env_as_map(&self) -> Result, 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) dockerfile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) args: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) additional_contexts: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)] +pub(crate) struct DockerComposeService { + pub(crate) image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) entrypoint: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) cap_add: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) security_opt: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) labels: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) build: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) privileged: Option, + pub(crate) volumes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) env_file: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) ports: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) network_mode: Option, +} + +#[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, + pub(crate) services: HashMap, + pub(crate) volumes: HashMap, +} + +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) -> 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) -> 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 { + // 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 = 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, + ) -> Result, 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, + 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, + 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 = + vec![inner_command.get_program().display().to_string()]; + let mut args: Vec = 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, + ) -> Result, 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; + async fn get_docker_compose_config( + &self, + config_files: &Vec, + ) -> Result, DevContainerError>; + async fn docker_compose_build( + &self, + config_files: &Vec, + project_name: &str, + ) -> Result<(), DevContainerError>; + async fn run_docker_exec( + &self, + container_id: &str, + remote_folder: &str, + user: &str, + env: &HashMap, + inner_command: Command, + ) -> Result<(), DevContainerError>; + async fn start_container(&self, id: &str) -> Result<(), DevContainerError>; + async fn find_process_by_filters( + &self, + filters: Vec, + ) -> Result, 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>>, D::Error> +where + D: Deserializer<'de>, +{ + let s: Option = Option::deserialize(deserializer)?; + match s { + Some(json_string) => { + let parsed: Vec> = + 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 { + 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::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 = 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 = 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…", + "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 = deserialize_json_output(full_output).unwrap(); + + assert!(result.is_some()); + let result = result.unwrap(); + assert_eq!(result.id, "abdb6ab59573".to_string()); + } + + #[test] + fn should_get_target_dir_from_docker_inspect() { + let given_config = r#" + { + "Id": "abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85", + "Created": "2026-02-04T23:44:21.802688084Z", + "Path": "/bin/sh", + "Args": [ + "-c", + "echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done", + "-" + ], + "State": { + "Status": "running", + "Running": true, + "Paused": false, + "Restarting": false, + "OOMKilled": false, + "Dead": false, + "Pid": 23087, + "ExitCode": 0, + "Error": "", + "StartedAt": "2026-02-04T23:44:21.954875084Z", + "FinishedAt": "0001-01-01T00:00:00Z" + }, + "Image": "sha256:3dcb059253b2ebb44de3936620e1cff3dadcd2c1c982d579081ca8128c1eb319", + "ResolvConfPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/resolv.conf", + "HostnamePath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hostname", + "HostsPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hosts", + "LogPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85-json.log", + "Name": "/objective_haslett", + "RestartCount": 0, + "Driver": "overlayfs", + "Platform": "linux", + "MountLabel": "", + "ProcessLabel": "", + "AppArmorProfile": "", + "ExecIDs": [ + "008019d93df4107fcbba78bcc6e1ed7e121844f36c26aca1a56284655a6adb53" + ], + "HostConfig": { + "Binds": null, + "ContainerIDFile": "", + "LogConfig": { + "Type": "json-file", + "Config": {} + }, + "NetworkMode": "bridge", + "PortBindings": {}, + "RestartPolicy": { + "Name": "no", + "MaximumRetryCount": 0 + }, + "AutoRemove": false, + "VolumeDriver": "", + "VolumesFrom": null, + "ConsoleSize": [ + 0, + 0 + ], + "CapAdd": null, + "CapDrop": null, + "CgroupnsMode": "private", + "Dns": [], + "DnsOptions": [], + "DnsSearch": [], + "ExtraHosts": null, + "GroupAdd": null, + "IpcMode": "private", + "Cgroup": "", + "Links": null, + "OomScoreAdj": 0, + "PidMode": "", + "Privileged": false, + "PublishAllPorts": false, + "ReadonlyRootfs": false, + "SecurityOpt": null, + "UTSMode": "", + "UsernsMode": "", + "ShmSize": 67108864, + "Runtime": "runc", + "Isolation": "", + "CpuShares": 0, + "Memory": 0, + "NanoCpus": 0, + "CgroupParent": "", + "BlkioWeight": 0, + "BlkioWeightDevice": [], + "BlkioDeviceReadBps": [], + "BlkioDeviceWriteBps": [], + "BlkioDeviceReadIOps": [], + "BlkioDeviceWriteIOps": [], + "CpuPeriod": 0, + "CpuQuota": 0, + "CpuRealtimePeriod": 0, + "CpuRealtimeRuntime": 0, + "CpusetCpus": "", + "CpusetMems": "", + "Devices": [], + "DeviceCgroupRules": null, + "DeviceRequests": null, + "MemoryReservation": 0, + "MemorySwap": 0, + "MemorySwappiness": null, + "OomKillDisable": null, + "PidsLimit": null, + "Ulimits": [], + "CpuCount": 0, + "CpuPercent": 0, + "IOMaximumIOps": 0, + "IOMaximumBandwidth": 0, + "Mounts": [ + { + "Type": "bind", + "Source": "/somepath/cli", + "Target": "/workspaces/cli", + "Consistency": "cached" + } + ], + "MaskedPaths": [ + "/proc/asound", + "/proc/acpi", + "/proc/interrupts", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/timer_list", + "/proc/timer_stats", + "/proc/sched_debug", + "/proc/scsi", + "/sys/firmware", + "/sys/devices/virtual/powercap" + ], + "ReadonlyPaths": [ + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sys", + "/proc/sysrq-trigger" + ] + }, + "GraphDriver": { + "Data": null, + "Name": "overlayfs" + }, + "Mounts": [ + { + "Type": "bind", + "Source": "/somepath/cli", + "Destination": "/workspaces/cli", + "Mode": "", + "RW": true, + "Propagation": "rprivate" + } + ], + "Config": { + "Hostname": "abdb6ab59573", + "Domainname": "", + "User": "root", + "AttachStdin": false, + "AttachStdout": true, + "AttachStderr": true, + "Tty": false, + "OpenStdin": false, + "StdinOnce": false, + "Env": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ], + "Cmd": [ + "-c", + "echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done", + "-" + ], + "Image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "Volumes": null, + "WorkingDir": "", + "Entrypoint": [ + "/bin/sh" + ], + "OnBuild": null, + "Labels": { + "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" + }, + "StopTimeout": 1 + }, + "NetworkSettings": { + "Bridge": "", + "SandboxID": "2a94990d542fe532deb75f1cc67f761df2d669e3b41161f914079e88516cc54b", + "SandboxKey": "/var/run/docker/netns/2a94990d542f", + "Ports": {}, + "HairpinMode": false, + "LinkLocalIPv6Address": "", + "LinkLocalIPv6PrefixLen": 0, + "SecondaryIPAddresses": null, + "SecondaryIPv6Addresses": null, + "EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570", + "Gateway": "172.17.0.1", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "IPAddress": "172.17.0.3", + "IPPrefixLen": 16, + "IPv6Gateway": "", + "MacAddress": "", + "Networks": { + "bridge": { + "IPAMConfig": null, + "Links": null, + "Aliases": null, + "MacAddress": "9a:ec:af:8a:ac:81", + "DriverOpts": null, + "GwPriority": 0, + "NetworkID": "51bb8ccc4d1281db44f16d915963fc728619d4a68e2f90e5ea8f1cb94885063e", + "EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570", + "Gateway": "172.17.0.1", + "IPAddress": "172.17.0.3", + "IPPrefixLen": 16, + "IPv6Gateway": "", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "DNSNames": null + } + } + }, + "ImageManifestDescriptor": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:39c3436527190561948236894c55b59fa58aa08d68d8867e703c8d5ab72a3593", + "size": 2195, + "platform": { + "architecture": "arm64", + "os": "linux" + } + } + } + "#; + let config = serde_json_lenient::from_str::(given_config).unwrap(); + + let target_dir = get_remote_dir_from_config(&config, "/somepath/cli".to_string()); + + assert!(target_dir.is_ok()); + assert_eq!(target_dir.unwrap(), "/workspaces/cli/".to_string()); + } + + #[test] + fn should_deserialize_docker_compose_config() { + let given_config = r#" + { + "name": "devcontainer", + "networks": { + "default": { + "name": "devcontainer_default", + "ipam": {} + } + }, + "services": { + "app": { + "command": [ + "sleep", + "infinity" + ], + "depends_on": { + "db": { + "condition": "service_started", + "restart": true, + "required": true + } + }, + "entrypoint": null, + "environment": { + "POSTGRES_DB": "postgres", + "POSTGRES_HOSTNAME": "localhost", + "POSTGRES_PASSWORD": "postgres", + "POSTGRES_PORT": "5432", + "POSTGRES_USER": "postgres" + }, + "image": "mcr.microsoft.com/devcontainers/rust:2-1-bookworm", + "network_mode": "service:db", + "volumes": [ + { + "type": "bind", + "source": "/path/to", + "target": "/workspaces", + "bind": { + "create_host_path": true + } + } + ] + }, + "db": { + "command": null, + "entrypoint": null, + "environment": { + "POSTGRES_DB": "postgres", + "POSTGRES_HOSTNAME": "localhost", + "POSTGRES_PASSWORD": "postgres", + "POSTGRES_PORT": "5432", + "POSTGRES_USER": "postgres" + }, + "image": "postgres:14.1", + "networks": { + "default": null + }, + "restart": "unless-stopped", + "volumes": [ + { + "type": "volume", + "source": "postgres-data", + "target": "/var/lib/postgresql/data", + "volume": {} + } + ] + } + }, + "volumes": { + "postgres-data": { + "name": "devcontainer_postgres-data" + } + } + } + "#; + + let docker_compose_config: DockerComposeConfig = + serde_json_lenient::from_str(given_config).unwrap(); + + let expected_config = DockerComposeConfig { + name: Some("devcontainer".to_string()), + services: HashMap::from([ + ( + "app".to_string(), + DockerComposeService { + image: Some( + "mcr.microsoft.com/devcontainers/rust:2-1-bookworm".to_string(), + ), + volumes: vec![MountDefinition { + mount_type: Some("bind".to_string()), + source: "/path/to".to_string(), + target: "/workspaces".to_string(), + }], + network_mode: Some("service:db".to_string()), + ..Default::default() + }, + ), + ( + "db".to_string(), + DockerComposeService { + image: Some("postgres:14.1".to_string()), + volumes: vec![MountDefinition { + mount_type: Some("volume".to_string()), + source: "postgres-data".to_string(), + target: "/var/lib/postgresql/data".to_string(), + }], + ..Default::default() + }, + ), + ]), + volumes: HashMap::from([( + "postgres-data".to_string(), + DockerComposeVolume { + name: "devcontainer_postgres-data".to_string(), + }, + )]), + }; + + assert_eq!(docker_compose_config, expected_config); + } +} diff --git a/crates/dev_container/src/features.rs b/crates/dev_container/src/features.rs new file mode 100644 index 0000000000000000000000000000000000000000..5c35b785852735f2c5c5bf8a5b9f73a3300097c5 --- /dev/null +++ b/crates/dev_container/src/features.rs @@ -0,0 +1,254 @@ +use std::{collections::HashMap, path::PathBuf, sync::Arc}; + +use fs::Fs; +use serde::Deserialize; +use serde_json_lenient::Value; + +use crate::{ + devcontainer_api::DevContainerError, + devcontainer_json::{FeatureOptions, MountDefinition}, + safe_id_upper, +}; + +/// Parsed components of an OCI feature reference such as +/// `ghcr.io/devcontainers/features/aws-cli:1`. +/// +/// Mirrors the CLI's `OCIRef` in `containerCollectionsOCI.ts`. +#[derive(Debug, Clone)] +pub(crate) struct OciFeatureRef { + /// Registry hostname, e.g. `ghcr.io` + pub registry: String, + /// Full repository path within the registry, e.g. `devcontainers/features/aws-cli` + pub path: String, + /// Version tag, digest, or `latest` + pub version: String, +} + +/// Minimal representation of a `devcontainer-feature.json` file, used to +/// extract option default values after the feature tarball is downloaded. +/// +/// See: https://containers.dev/implementors/features/#devcontainer-featurejson-properties +#[derive(Debug, Deserialize, Eq, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DevContainerFeatureJson { + #[serde(rename = "id")] + pub(crate) _id: Option, + #[serde(default)] + pub(crate) options: HashMap, + pub(crate) mounts: Option>, + pub(crate) privileged: Option, + pub(crate) entrypoint: Option, + pub(crate) container_env: Option>, +} + +/// A single option definition inside `devcontainer-feature.json`. +/// We only need the `default` field to populate env variables. +#[derive(Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct FeatureOptionDefinition { + pub(crate) default: Option, +} + +impl FeatureOptionDefinition { + fn serialize_default(&self) -> Option { + self.default.as_ref().map(|some_value| match some_value { + Value::Bool(b) => b.to_string(), + Value::String(s) => s.to_string(), + Value::Number(n) => n.to_string(), + other => other.to_string(), + }) + } +} + +#[derive(Debug, Eq, PartialEq, Default)] +pub(crate) struct FeatureManifest { + consecutive_id: String, + file_path: PathBuf, + feature_json: DevContainerFeatureJson, +} + +impl FeatureManifest { + pub(crate) fn new( + consecutive_id: String, + file_path: PathBuf, + feature_json: DevContainerFeatureJson, + ) -> Self { + Self { + consecutive_id, + file_path, + feature_json, + } + } + pub(crate) fn container_env(&self) -> HashMap { + self.feature_json.container_env.clone().unwrap_or_default() + } + + pub(crate) fn generate_dockerfile_feature_layer( + &self, + use_buildkit: bool, + dest: &str, + ) -> String { + let id = &self.consecutive_id; + if use_buildkit { + format!( + r#" +RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./{id},target=/tmp/build-features-src/{id} \ +cp -ar /tmp/build-features-src/{id} {dest} \ +&& chmod -R 0755 {dest}/{id} \ +&& cd {dest}/{id} \ +&& chmod +x ./devcontainer-features-install.sh \ +&& ./devcontainer-features-install.sh \ +&& rm -rf {dest}/{id} +"#, + ) + } else { + let source = format!("/tmp/build-features/{id}"); + let full_dest = format!("{dest}/{id}"); + format!( + r#" +COPY --chown=root:root --from=dev_containers_feature_content_source {source} {full_dest} +RUN chmod -R 0755 {full_dest} \ +&& cd {full_dest} \ +&& chmod +x ./devcontainer-features-install.sh \ +&& ./devcontainer-features-install.sh +"# + ) + } + } + + pub(crate) fn generate_dockerfile_env(&self) -> String { + let mut layer = "".to_string(); + let env = self.container_env(); + let mut env: Vec<(&String, &String)> = env.iter().collect(); + env.sort(); + + for (key, value) in env { + layer = format!("{layer}ENV {key}={value}\n") + } + layer + } + + /// Merges user options from devcontainer.json with default options defined in this feature manifest + pub(crate) fn generate_merged_env(&self, options: &FeatureOptions) -> HashMap { + let mut merged: HashMap = self + .feature_json + .options + .iter() + .filter_map(|(k, v)| { + v.serialize_default() + .map(|v_some| (safe_id_upper(k), v_some)) + }) + .collect(); + + match options { + FeatureOptions::Bool(_) => {} + FeatureOptions::String(version) => { + merged.insert("VERSION".to_string(), version.clone()); + } + FeatureOptions::Options(map) => { + for (key, value) in map { + merged.insert(safe_id_upper(key), value.to_string()); + } + } + } + merged + } + + pub(crate) async fn write_feature_env( + &self, + fs: &Arc, + options: &FeatureOptions, + ) -> Result { + let merged_env = self.generate_merged_env(options); + + let mut env_vars: Vec<(&String, &String)> = merged_env.iter().collect(); + env_vars.sort(); + + let env_file_content = env_vars + .iter() + .fold("".to_string(), |acc, (k, v)| format!("{acc}{}={}\n", k, v)); + + fs.write( + &self.file_path.join("devcontainer-features.env"), + env_file_content.as_bytes(), + ) + .await + .map_err(|e| { + log::error!("error writing devcontainer feature environment: {e}"); + DevContainerError::FilesystemError + })?; + + Ok(env_file_content) + } + + pub(crate) fn mounts(&self) -> Vec { + if let Some(mounts) = &self.feature_json.mounts { + mounts.clone() + } else { + vec![] + } + } + + pub(crate) fn privileged(&self) -> bool { + self.feature_json.privileged.unwrap_or(false) + } + + pub(crate) fn entrypoint(&self) -> Option { + self.feature_json.entrypoint.clone() + } + + pub(crate) fn file_path(&self) -> PathBuf { + self.file_path.clone() + } +} + +/// Parses an OCI feature reference string into its components. +/// +/// Handles formats like: +/// - `ghcr.io/devcontainers/features/aws-cli:1` +/// - `ghcr.io/user/repo/go` (implicitly `:latest`) +/// - `ghcr.io/devcontainers/features/rust@sha256:abc123` +/// +/// Returns `None` for local paths (`./…`) and direct tarball URIs (`https://…`). +pub(crate) fn parse_oci_feature_ref(input: &str) -> Option { + if input.starts_with('.') + || input.starts_with('/') + || input.starts_with("https://") + || input.starts_with("http://") + { + return None; + } + + let input_lower = input.to_lowercase(); + + let (resource, version) = if let Some(at_idx) = input_lower.rfind('@') { + // Digest-based: ghcr.io/foo/bar@sha256:abc + ( + input_lower[..at_idx].to_string(), + input_lower[at_idx + 1..].to_string(), + ) + } else { + let last_slash = input_lower.rfind('/'); + let last_colon = input_lower.rfind(':'); + match (last_slash, last_colon) { + (Some(slash), Some(colon)) if colon > slash => ( + input_lower[..colon].to_string(), + input_lower[colon + 1..].to_string(), + ), + _ => (input_lower, "latest".to_string()), + } + }; + + let parts: Vec<&str> = resource.split('/').collect(); + if parts.len() < 3 { + return None; + } + + let registry = parts[0].to_string(); + let path = parts[1..].join("/"); + + Some(OciFeatureRef { + registry, + path, + version, + }) +} diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs index 7fcacf8004bef6c4c26e2751df6f26c02b4629ce..601394c77760bc79587f5dad3cb7e0bf8a310af3 100644 --- a/crates/dev_container/src/lib.rs +++ b/crates/dev_container/src/lib.rs @@ -1,11 +1,14 @@ use std::path::Path; +use fs::Fs; use gpui::AppContext; use gpui::Entity; use gpui::Task; +use gpui::WeakEntity; use http_client::anyhow; use picker::Picker; use picker::PickerDelegate; +use project::ProjectEnvironment; use settings::RegisterSetting; use settings::Settings; use std::collections::HashMap; @@ -25,8 +28,9 @@ use ui::Tooltip; use ui::h_flex; use ui::rems_from_px; use ui::v_flex; +use util::shell::Shell; -use gpui::{Action, DismissEvent, EventEmitter, FocusHandle, Focusable, RenderOnce, WeakEntity}; +use gpui::{Action, DismissEvent, EventEmitter, FocusHandle, Focusable, RenderOnce}; use serde::Deserialize; use ui::{ AnyElement, App, Color, CommonAnimationExt, Context, Headline, HeadlineSize, Icon, IconName, @@ -37,40 +41,94 @@ use util::ResultExt; use util::rel_path::RelPath; use workspace::{ModalView, Workspace, with_active_or_new_workspace}; -use futures::AsyncReadExt; -use http::Request; -use http_client::{AsyncBody, HttpClient}; +use http_client::HttpClient; +mod command_json; mod devcontainer_api; +mod devcontainer_json; +mod devcontainer_manifest; +mod docker; +mod features; +mod oci; -use devcontainer_api::ensure_devcontainer_cli; -use devcontainer_api::read_devcontainer_configuration; +use devcontainer_api::read_default_devcontainer_configuration; use crate::devcontainer_api::DevContainerError; -use crate::devcontainer_api::apply_dev_container_template; +use crate::devcontainer_api::apply_devcontainer_template; +use crate::oci::get_deserializable_oci_blob; +use crate::oci::get_latest_oci_manifest; +use crate::oci::get_oci_token; pub use devcontainer_api::{ DevContainerConfig, find_configs_in_snapshot, find_devcontainer_configs, start_dev_container_with_config, }; +/// Converts a string to a safe environment variable name. +/// +/// Mirrors the CLI's `getSafeId` in `containerFeatures.ts`: +/// replaces non-alphanumeric/underscore characters with `_`, replaces a +/// leading sequence of digits/underscores with a single `_`, and uppercases. +pub(crate) fn safe_id_lower(input: &str) -> String { + get_safe_id(input).to_lowercase() +} +pub(crate) fn safe_id_upper(input: &str) -> String { + get_safe_id(input).to_uppercase() +} +fn get_safe_id(input: &str) -> String { + let replaced: String = input + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '_' { + c + } else { + '_' + } + }) + .collect(); + let without_leading = replaced.trim_start_matches(|c: char| c.is_ascii_digit() || c == '_'); + let result = if without_leading.len() < replaced.len() { + format!("_{}", without_leading) + } else { + replaced + }; + result +} + pub struct DevContainerContext { pub project_directory: Arc, pub use_podman: bool, - pub node_runtime: node_runtime::NodeRuntime, + pub fs: Arc, + pub http_client: Arc, + pub environment: WeakEntity, } impl DevContainerContext { pub fn from_workspace(workspace: &Workspace, cx: &App) -> Option { let project_directory = workspace.project().read(cx).active_project_directory(cx)?; let use_podman = DevContainerSettings::get_global(cx).use_podman; - let node_runtime = workspace.app_state().node_runtime.clone(); + let http_client = cx.http_client().clone(); + let fs = workspace.app_state().fs.clone(); + let environment = workspace.project().read(cx).environment().downgrade(); Some(Self { project_directory, use_podman, - node_runtime, + fs, + http_client, + environment, }) } + + pub async fn environment(&self, cx: &mut impl AppContext) -> HashMap { + let Ok(task) = self.environment.update(cx, |this, cx| { + this.local_directory_environment(&Shell::System, self.project_directory.clone(), cx) + }) else { + return HashMap::default(); + }; + task.await + .map(|env| env.into_iter().collect::>()) + .unwrap_or_default() + } } #[derive(RegisterSetting)] @@ -1043,7 +1101,7 @@ impl StatefulModal for DevContainerModal { let Ok(client) = cx.update(|_, cx| cx.http_client()) else { return; }; - match get_templates(client).await { + match get_ghcr_templates(client).await { Ok(templates) => { let message = DevContainerMessage::TemplatesRetrieved(templates.templates); @@ -1209,7 +1267,7 @@ impl StatefulModal for DevContainerModal { let Ok(client) = cx.update(|_, cx| cx.http_client()) else { return; }; - let Some(features) = get_features(client).await.log_err() else { + let Some(features) = get_ghcr_features(client).await.log_err() else { return; }; let message = DevContainerMessage::FeaturesRetrieved(features.features); @@ -1328,17 +1386,7 @@ trait StatefulModal: ModalView + EventEmitter + Render { } } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct GithubTokenResponse { - token: String, -} - -fn ghcr_url() -> &'static str { - "https://ghcr.io" -} - -fn ghcr_domain() -> &'static str { +fn ghcr_registry() -> &'static str { "ghcr.io" } @@ -1350,11 +1398,6 @@ fn devcontainer_features_repository() -> &'static str { "devcontainers/features" } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ManifestLayer { - digest: String, -} #[derive(Debug, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct TemplateOptions { @@ -1409,12 +1452,6 @@ impl TemplateOptions { } } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct DockerManifestsResponse { - layers: Vec, -} - #[derive(Debug, Deserialize, Clone, PartialEq, Eq, Hash)] #[serde(rename_all = "camelCase")] struct DevContainerFeature { @@ -1480,23 +1517,11 @@ fn dispatch_apply_templates( return; }; - let Ok(cli) = ensure_devcontainer_cli(&context.node_runtime).await else { - this.update_in(cx, |this, window, cx| { - this.accept_message( - DevContainerMessage::FailedToWriteTemplate( - DevContainerError::DevContainerCliNotAvailable, - ), - window, - cx, - ); - }) - .log_err(); - return; - }; + let environment = context.environment(cx).await; { if check_for_existing - && read_devcontainer_configuration(&context, &cli, None) + && read_default_devcontainer_configuration(&context, environment) .await .is_ok() { @@ -1511,12 +1536,17 @@ fn dispatch_apply_templates( return; } - let files = match apply_dev_container_template( + let worktree = workspace.read_with(cx, |workspace, cx| { + workspace.project().read(cx).worktree_for_id(tree_id, cx) + }); + + let files = match apply_devcontainer_template( + worktree.unwrap(), &template_entry.template, &template_entry.options_selected, &template_entry.features_selected, &context, - &cli, + cx, ) .await { @@ -1524,7 +1554,9 @@ fn dispatch_apply_templates( Err(e) => { this.update_in(cx, |this, window, cx| { this.accept_message( - DevContainerMessage::FailedToWriteTemplate(e), + DevContainerMessage::FailedToWriteTemplate( + DevContainerError::DevContainerTemplateApplyFailed(e.to_string()), + ), window, cx, ); @@ -1534,10 +1566,9 @@ fn dispatch_apply_templates( } }; - if files - .files - .contains(&"./.devcontainer/devcontainer.json".to_string()) - { + if files.project_files.contains(&Arc::from( + RelPath::unix(".devcontainer/devcontainer.json").unwrap(), + )) { let Some(workspace_task) = workspace .update_in(cx, |workspace, window, cx| { let Ok(path) = RelPath::unix(".devcontainer/devcontainer.json") else { @@ -1563,250 +1594,90 @@ fn dispatch_apply_templates( .detach(); } -async fn get_templates( +async fn get_ghcr_templates( client: Arc, ) -> Result { - let token = get_ghcr_token(&client).await?; - let manifest = get_latest_manifest(&token.token, &client).await?; - - let mut template_response = - get_devcontainer_templates(&token.token, &manifest.layers[0].digest, &client).await?; + let token = get_oci_token( + ghcr_registry(), + devcontainer_templates_repository(), + &client, + ) + .await?; + let manifest = get_latest_oci_manifest( + &token.token, + ghcr_registry(), + devcontainer_templates_repository(), + &client, + None, + ) + .await?; + + let mut template_response: DevContainerTemplatesResponse = get_deserializable_oci_blob( + &token.token, + ghcr_registry(), + devcontainer_templates_repository(), + &manifest.layers[0].digest, + &client, + ) + .await?; for template in &mut template_response.templates { template.source_repository = Some(format!( "{}/{}", - ghcr_domain(), + ghcr_registry(), devcontainer_templates_repository() )); } Ok(template_response) } -async fn get_features(client: Arc) -> Result { - let token = get_ghcr_token(&client).await?; - let manifest = get_latest_feature_manifest(&token.token, &client).await?; +async fn get_ghcr_features( + client: Arc, +) -> Result { + let token = get_oci_token( + ghcr_registry(), + devcontainer_templates_repository(), + &client, + ) + .await?; - let mut features_response = - get_devcontainer_features(&token.token, &manifest.layers[0].digest, &client).await?; + let manifest = get_latest_oci_manifest( + &token.token, + ghcr_registry(), + devcontainer_features_repository(), + &client, + None, + ) + .await?; + + let mut features_response: DevContainerFeaturesResponse = get_deserializable_oci_blob( + &token.token, + ghcr_registry(), + devcontainer_features_repository(), + &manifest.layers[0].digest, + &client, + ) + .await?; for feature in &mut features_response.features { feature.source_repository = Some(format!( "{}/{}", - ghcr_domain(), + ghcr_registry(), devcontainer_features_repository() )); } Ok(features_response) } -async fn get_ghcr_token(client: &Arc) -> Result { - let url = format!( - "{}/token?service=ghcr.io&scope=repository:{}:pull", - ghcr_url(), - devcontainer_templates_repository() - ); - get_deserialized_response("", &url, client).await -} - -async fn get_latest_feature_manifest( - token: &str, - client: &Arc, -) -> Result { - let url = format!( - "{}/v2/{}/manifests/latest", - ghcr_url(), - devcontainer_features_repository() - ); - get_deserialized_response(token, &url, client).await -} - -async fn get_latest_manifest( - token: &str, - client: &Arc, -) -> Result { - let url = format!( - "{}/v2/{}/manifests/latest", - ghcr_url(), - devcontainer_templates_repository() - ); - get_deserialized_response(token, &url, client).await -} - -async fn get_devcontainer_features( - token: &str, - blob_digest: &str, - client: &Arc, -) -> Result { - let url = format!( - "{}/v2/{}/blobs/{}", - ghcr_url(), - devcontainer_features_repository(), - blob_digest - ); - get_deserialized_response(token, &url, client).await -} - -async fn get_devcontainer_templates( - token: &str, - blob_digest: &str, - client: &Arc, -) -> Result { - let url = format!( - "{}/v2/{}/blobs/{}", - ghcr_url(), - devcontainer_templates_repository(), - blob_digest - ); - get_deserialized_response(token, &url, client).await -} - -async fn get_deserialized_response( - token: &str, - url: &str, - client: &Arc, -) -> Result -where - T: for<'de> Deserialize<'de>, -{ - let request = match Request::get(url) - .header("Authorization", format!("Bearer {}", token)) - .header("Accept", "application/vnd.oci.image.manifest.v1+json") - .body(AsyncBody::default()) - { - Ok(request) => request, - Err(e) => return Err(format!("Failed to create request: {}", e)), - }; - let response = match client.send(request).await { - Ok(response) => response, - Err(e) => { - return Err(format!("Failed to send request: {}", e)); - } - }; - - let mut output = String::new(); - - if let Err(e) = response.into_body().read_to_string(&mut output).await { - return Err(format!("Failed to read response body: {}", e)); - }; - - match serde_json::from_str(&output) { - Ok(response) => Ok(response), - Err(e) => Err(format!("Failed to deserialize response: {}", e)), - } -} - #[cfg(test)] mod tests { - use gpui::TestAppContext; use http_client::{FakeHttpClient, anyhow}; use crate::{ - GithubTokenResponse, devcontainer_templates_repository, get_deserialized_response, - get_devcontainer_templates, get_ghcr_token, get_latest_manifest, + DevContainerTemplatesResponse, devcontainer_templates_repository, + get_deserializable_oci_blob, ghcr_registry, }; - #[gpui::test] - async fn test_get_deserialized_response(_cx: &mut TestAppContext) { - let client = FakeHttpClient::create(|_request| async move { - Ok(http_client::Response::builder() - .status(200) - .body("{ \"token\": \"thisisatoken\" }".into()) - .unwrap()) - }); - - let response = - get_deserialized_response::("", "https://ghcr.io/token", &client) - .await; - assert!(response.is_ok()); - assert_eq!(response.unwrap().token, "thisisatoken".to_string()) - } - - #[gpui::test] - async fn test_get_ghcr_token() { - let client = FakeHttpClient::create(|request| async move { - let host = request.uri().host(); - if host.is_none() || host.unwrap() != "ghcr.io" { - return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default())); - } - let path = request.uri().path(); - if path != "/token" { - return Err(anyhow!("Unexpected path: {}", path)); - } - let query = request.uri().query(); - if query.is_none() - || query.unwrap() - != format!( - "service=ghcr.io&scope=repository:{}:pull", - devcontainer_templates_repository() - ) - { - return Err(anyhow!("Unexpected query: {}", query.unwrap_or_default())); - } - Ok(http_client::Response::builder() - .status(200) - .body("{ \"token\": \"thisisatoken\" }".into()) - .unwrap()) - }); - - let response = get_ghcr_token(&client).await; - assert!(response.is_ok()); - assert_eq!(response.unwrap().token, "thisisatoken".to_string()); - } - - #[gpui::test] - async fn test_get_latest_manifests() { - let client = FakeHttpClient::create(|request| async move { - let host = request.uri().host(); - if host.is_none() || host.unwrap() != "ghcr.io" { - return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default())); - } - let path = request.uri().path(); - if path - != format!( - "/v2/{}/manifests/latest", - devcontainer_templates_repository() - ) - { - return Err(anyhow!("Unexpected path: {}", path)); - } - Ok(http_client::Response::builder() - .status(200) - .body("{ - \"schemaVersion\": 2, - \"mediaType\": \"application/vnd.oci.image.manifest.v1+json\", - \"config\": { - \"mediaType\": \"application/vnd.devcontainers\", - \"digest\": \"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\", - \"size\": 2 - }, - \"layers\": [ - { - \"mediaType\": \"application/vnd.devcontainers.collection.layer.v1+json\", - \"digest\": \"sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09\", - \"size\": 65235, - \"annotations\": { - \"org.opencontainers.image.title\": \"devcontainer-collection.json\" - } - } - ], - \"annotations\": { - \"com.github.package.type\": \"devcontainer_collection\" - } - }".into()) - .unwrap()) - }); - - let response = get_latest_manifest("", &client).await; - assert!(response.is_ok()); - let response = response.unwrap(); - - assert_eq!(response.layers.len(), 1); - assert_eq!( - response.layers[0].digest, - "sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09" - ); - } - #[gpui::test] async fn test_get_devcontainer_templates() { let client = FakeHttpClient::create(|request| async move { @@ -1872,8 +1743,10 @@ mod tests { }".into()) .unwrap()) }); - let response = get_devcontainer_templates( + let response: Result = get_deserializable_oci_blob( "", + ghcr_registry(), + devcontainer_templates_repository(), "sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09", &client, ) diff --git a/crates/dev_container/src/oci.rs b/crates/dev_container/src/oci.rs new file mode 100644 index 0000000000000000000000000000000000000000..483f706edb919fc4031577caddd7d33558112532 --- /dev/null +++ b/crates/dev_container/src/oci.rs @@ -0,0 +1,470 @@ +use std::{path::PathBuf, pin::Pin, sync::Arc}; + +use fs::Fs; +use futures::{AsyncRead, AsyncReadExt, io::BufReader}; +use http::Request; +use http_client::{AsyncBody, HttpClient}; +use serde::{Deserialize, Serialize}; + +use crate::devcontainer_api::DevContainerError; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TokenResponse { + pub(crate) token: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DockerManifestsResponse { + pub(crate) layers: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ManifestLayer { + pub(crate) digest: String, +} + +/// Gets a bearer token for pulling from a container registry repository. +/// +/// This uses the registry's `/token` endpoint directly, which works for +/// `ghcr.io` and other registries that follow the same convention. For +/// registries that require a full `WWW-Authenticate` negotiation flow this +/// would need to be extended. +pub(crate) async fn get_oci_token( + registry: &str, + repository_path: &str, + client: &Arc, +) -> Result { + let url = format!( + "https://{registry}/token?service={registry}&scope=repository:{repository_path}:pull", + ); + log::debug!("Fetching OCI token from: {}", url); + get_deserialized_response("", &url, client) + .await + .map_err(|e| { + log::error!("OCI token request failed for {}: {e}", url); + e + }) +} + +pub(crate) async fn get_latest_oci_manifest( + token: &str, + registry: &str, + repository_path: &str, + client: &Arc, + id: Option<&str>, +) -> Result { + get_oci_manifest(registry, repository_path, token, client, "latest", id).await +} + +pub(crate) async fn get_oci_manifest( + registry: &str, + repository_path: &str, + token: &str, + client: &Arc, + version: &str, + id: Option<&str>, +) -> Result { + let url = match id { + Some(id) => format!("https://{registry}/v2/{repository_path}/{id}/manifests/{version}"), + None => format!("https://{registry}/v2/{repository_path}/manifests/{version}"), + }; + + get_deserialized_response(token, &url, client).await +} + +pub(crate) async fn get_deserializable_oci_blob( + token: &str, + registry: &str, + repository_path: &str, + blob_digest: &str, + client: &Arc, +) -> Result +where + T: for<'a> Deserialize<'a>, +{ + let url = format!("https://{registry}/v2/{repository_path}/blobs/{blob_digest}"); + get_deserialized_response(token, &url, client).await +} + +pub(crate) async fn download_oci_tarball( + token: &str, + registry: &str, + repository_path: &str, + blob_digest: &str, + accept_header: &str, + dest_dir: &PathBuf, + client: &Arc, + fs: &Arc, + id: Option<&str>, +) -> Result<(), DevContainerError> { + let url = match id { + Some(id) => format!("https://{registry}/v2/{repository_path}/{id}/blobs/{blob_digest}"), + None => format!("https://{registry}/v2/{repository_path}/blobs/{blob_digest}"), + }; + + let request = Request::get(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Accept", accept_header) + .body(AsyncBody::default()) + .map_err(|e| { + log::error!("Failed to create blob request: {e}"); + DevContainerError::ResourceFetchFailed + })?; + + let mut response = client.send(request).await.map_err(|e| { + log::error!("Failed to download feature blob: {e}"); + DevContainerError::ResourceFetchFailed + })?; + let status = response.status(); + + let body = BufReader::new(response.body_mut()); + + if !status.is_success() { + let body_text = String::from_utf8_lossy(body.buffer()); + log::error!( + "Feature blob download returned HTTP {}: {}", + status.as_u16(), + body_text, + ); + return Err(DevContainerError::ResourceFetchFailed); + } + + futures::pin_mut!(body); + let body: Pin<&mut (dyn AsyncRead + Send)> = body; + let archive = async_tar::Archive::new(body); + fs.extract_tar_file(dest_dir, archive).await.map_err(|e| { + log::error!("Failed to extract feature tarball: {e}"); + DevContainerError::FilesystemError + })?; + + Ok(()) +} + +pub(crate) async fn get_deserialized_response( + token: &str, + url: &str, + client: &Arc, +) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let request = match Request::get(url) + .header("Authorization", format!("Bearer {}", token)) + .header("Accept", "application/vnd.oci.image.manifest.v1+json") + .body(AsyncBody::default()) + { + Ok(request) => request, + Err(e) => return Err(format!("Failed to create request: {}", e)), + }; + let response = match client.send(request).await { + Ok(response) => response, + Err(e) => { + return Err(format!("Failed to send request to {}: {}", url, e)); + } + }; + + let status = response.status(); + let mut output = String::new(); + + if let Err(e) = response.into_body().read_to_string(&mut output).await { + return Err(format!("Failed to read response body from {}: {}", url, e)); + }; + + if !status.is_success() { + return Err(format!( + "OCI request to {} returned HTTP {}: {}", + url, + status.as_u16(), + &output[..output.len().min(500)], + )); + } + + match serde_json_lenient::from_str(&output) { + Ok(response) => Ok(response), + Err(e) => Err(format!( + "Failed to deserialize response from {}: {} (body: {})", + url, + e, + &output[..output.len().min(500)], + )), + } +} + +#[cfg(test)] +mod test { + use std::{path::PathBuf, sync::Arc}; + + use fs::{FakeFs, Fs}; + use gpui::TestAppContext; + use http_client::{FakeHttpClient, anyhow}; + use serde::Deserialize; + + use crate::oci::{ + TokenResponse, download_oci_tarball, get_deserializable_oci_blob, + get_deserialized_response, get_latest_oci_manifest, get_oci_token, + }; + + async fn build_test_tarball() -> Vec { + let devcontainer_json = concat!( + "// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n", + "// README at: https://github.com/devcontainers/templates/tree/main/src/alpine\n", + "{\n", + "\t\"name\": \"Alpine\",\n", + "\t// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile\n", + "\t\"image\": \"mcr.microsoft.com/devcontainers/base:alpine-${templateOption:imageVariant}\"\n", + "}\n", + ); + + let dependabot_yml = concat!( + "version: 2\n", + "updates:\n", + " - package-ecosystem: \"devcontainers\"\n", + " directory: \"/\"\n", + " schedule:\n", + " interval: weekly\n", + ); + + let buffer = futures::io::Cursor::new(Vec::new()); + let mut builder = async_tar::Builder::new(buffer); + + let files: &[(&str, &[u8], u32)] = &[ + ( + ".devcontainer/devcontainer.json", + devcontainer_json.as_bytes(), + 0o644, + ), + (".github/dependabot.yml", dependabot_yml.as_bytes(), 0o644), + ("NOTES.md", b"Some notes", 0o644), + ("README.md", b"# Alpine\n", 0o644), + ]; + + for (path, data, mode) in files { + let mut header = async_tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(*mode); + header.set_entry_type(async_tar::EntryType::Regular); + header.set_cksum(); + builder.append_data(&mut header, path, *data).await.unwrap(); + } + + let buffer = builder.into_inner().await.unwrap(); + buffer.into_inner() + } + fn test_oci_registry() -> &'static str { + "ghcr.io" + } + fn test_oci_repository() -> &'static str { + "repository" + } + + #[gpui::test] + async fn test_get_deserialized_response(_cx: &mut TestAppContext) { + let client = FakeHttpClient::create(|_request| async move { + Ok(http_client::Response::builder() + .status(200) + .body("{ \"token\": \"thisisatoken\" }".into()) + .unwrap()) + }); + + let response = + get_deserialized_response::("", "https://ghcr.io/token", &client).await; + assert!(response.is_ok()); + assert_eq!(response.unwrap().token, "thisisatoken".to_string()) + } + + #[gpui::test] + async fn test_get_oci_token() { + let client = FakeHttpClient::create(|request| async move { + let host = request.uri().host(); + if host.is_none() || host.unwrap() != test_oci_registry() { + return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default())); + } + let path = request.uri().path(); + if path != "/token" { + return Err(anyhow!("Unexpected path: {}", path)); + } + let query = request.uri().query(); + if query.is_none() + || query.unwrap() + != format!( + "service=ghcr.io&scope=repository:{}:pull", + test_oci_repository() + ) + { + return Err(anyhow!("Unexpected query: {}", query.unwrap_or_default())); + } + Ok(http_client::Response::builder() + .status(200) + .body("{ \"token\": \"thisisatoken\" }".into()) + .unwrap()) + }); + + let response = get_oci_token(test_oci_registry(), test_oci_repository(), &client).await; + + assert!(response.is_ok()); + assert_eq!(response.unwrap().token, "thisisatoken".to_string()); + } + + #[gpui::test] + async fn test_get_latest_manifests() { + let client = FakeHttpClient::create(|request| async move { + let host = request.uri().host(); + if host.is_none() || host.unwrap() != test_oci_registry() { + return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default())); + } + let path = request.uri().path(); + if path != format!("/v2/{}/manifests/latest", test_oci_repository()) { + return Err(anyhow!("Unexpected path: {}", path)); + } + Ok(http_client::Response::builder() + .status(200) + .body("{ + \"schemaVersion\": 2, + \"mediaType\": \"application/vnd.oci.image.manifest.v1+json\", + \"config\": { + \"mediaType\": \"application/vnd.devcontainers\", + \"digest\": \"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\", + \"size\": 2 + }, + \"layers\": [ + { + \"mediaType\": \"application/vnd.devcontainers.collection.layer.v1+json\", + \"digest\": \"sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09\", + \"size\": 65235, + \"annotations\": { + \"org.opencontainers.image.title\": \"devcontainer-collection.json\" + } + } + ], + \"annotations\": { + \"com.github.package.type\": \"devcontainer_collection\" + } + }".into()) + .unwrap()) + }); + + let response = get_latest_oci_manifest( + "", + test_oci_registry(), + test_oci_repository(), + &client, + None, + ) + .await; + assert!(response.is_ok()); + let response = response.unwrap(); + + assert_eq!(response.layers.len(), 1); + assert_eq!( + response.layers[0].digest, + "sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09" + ); + } + + #[gpui::test] + async fn test_get_oci_blob() { + #[derive(Debug, Deserialize)] + struct DeserializableTestStruct { + foo: String, + } + + let client = FakeHttpClient::create(|request| async move { + let host = request.uri().host(); + if host.is_none() || host.unwrap() != test_oci_registry() { + return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default())); + } + let path = request.uri().path(); + if path != format!("/v2/{}/blobs/blobdigest", test_oci_repository()) { + return Err(anyhow!("Unexpected path: {}", path)); + } + Ok(http_client::Response::builder() + .status(200) + .body( + r#" + { + "foo": "bar" + } + "# + .into(), + ) + .unwrap()) + }); + + let response: Result = get_deserializable_oci_blob( + "", + test_oci_registry(), + test_oci_repository(), + "blobdigest", + &client, + ) + .await; + assert!(response.is_ok()); + let response = response.unwrap(); + + assert_eq!(response.foo, "bar".to_string()); + } + + #[gpui::test] + async fn test_download_oci_tarball(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let fs: Arc = FakeFs::new(cx.executor()); + + let destination_dir = PathBuf::from("/tmp/extracted"); + fs.create_dir(&destination_dir).await.unwrap(); + + let tarball_bytes = build_test_tarball().await; + let tarball = std::sync::Arc::new(tarball_bytes); + + let client = FakeHttpClient::create(move |request| { + let tarball = tarball.clone(); + async move { + let host = request.uri().host(); + if host.is_none() || host.unwrap() != test_oci_registry() { + return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default())); + } + let path = request.uri().path(); + if path != format!("/v2/{}/blobs/blobdigest", test_oci_repository()) { + return Err(anyhow!("Unexpected path: {}", path)); + } + Ok(http_client::Response::builder() + .status(200) + .body(tarball.to_vec().into()) + .unwrap()) + } + }); + + let response = download_oci_tarball( + "", + test_oci_registry(), + test_oci_repository(), + "blobdigest", + "header", + &destination_dir, + &client, + &fs, + None, + ) + .await; + assert!(response.is_ok()); + + let expected_devcontainer_json = concat!( + "// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n", + "// README at: https://github.com/devcontainers/templates/tree/main/src/alpine\n", + "{\n", + "\t\"name\": \"Alpine\",\n", + "\t// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile\n", + "\t\"image\": \"mcr.microsoft.com/devcontainers/base:alpine-${templateOption:imageVariant}\"\n", + "}\n", + ); + + assert_eq!( + fs.load(&destination_dir.join(".devcontainer/devcontainer.json")) + .await + .unwrap(), + expected_devcontainer_json + ) + } +} diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 22987f6c56669e1972a9bfc940449991d9f55642..7194e8868fd2a0015edd5c18c96f2fe164206fb7 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -2068,9 +2068,16 @@ mod tests { ) .await; + // Open a file path (not a directory) so that the worktree root is a + // file. This means `active_project_directory` returns `None`, which + // causes `DevContainerContext::from_workspace` to return `None`, + // preventing `open_dev_container` from spawning real I/O (docker + // commands, shell environment loading) that is incompatible with the + // test scheduler. The modal is still created and the re-entrancy + // guard that this test validates is still exercised. cx.update(|cx| { open_paths( - &[PathBuf::from(path!("/project"))], + &[PathBuf::from(path!("/project/src/main.rs"))], app_state, workspace::OpenOptions::default(), cx, diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 3611b55ec65c94695e4e8835fa7afe8badc80a29..869568edfcdbe9260a13aaa5c0ed7eed6b87e675 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -96,6 +96,7 @@ impl From for RemoteConnectionOptions { container_id: conn.container_id, upload_binary_over_docker_exec: false, use_podman: conn.use_podman, + remote_env: conn.remote_env, }) } } diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 26592a8035d50caa4e267a5478d5aceb9fba6e3e..404b0673ab8cf220385d1a0ce41a40156d469a01 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -11,6 +11,7 @@ use dev_container::{ }; use editor::Editor; +use extension_host::ExtensionStore; use futures::{FutureExt, channel::oneshot, future::Shared}; use gpui::{ Action, AnyElement, App, ClickEvent, ClipboardItem, Context, DismissEvent, Entity, @@ -41,6 +42,7 @@ use std::{ atomic::{self, AtomicUsize}, }, }; + use ui::{ CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal, ModalFooter, ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar, @@ -1854,10 +1856,13 @@ impl RemoteServerProjects { ) { let replace_window = window.window_handle().downcast::(); let app_state = Arc::downgrade(&app_state); + cx.spawn_in(window, async move |entity, cx| { - let (connection, starting_dir) = - match start_dev_container_with_config(context, config).await { - Ok((c, s)) => (Connection::DevContainer(c), s), + let environment = context.environment(cx).await; + + let (dev_container_connection, starting_dir) = + match start_dev_container_with_config(context, config, environment).await { + Ok((c, s)) => (c, s), Err(e) => { log::error!("Failed to start dev container: {:?}", e); cx.prompt( @@ -1881,6 +1886,16 @@ impl RemoteServerProjects { return; } }; + cx.update(|_, cx| { + ExtensionStore::global(cx).update(cx, |this, cx| { + for extension in &dev_container_connection.extension_ids { + log::info!("Installing extension {extension} from devcontainer"); + this.install_latest_extension(Arc::from(extension.clone()), cx); + } + }) + }) + .log_err(); + entity .update(cx, |_, cx| { cx.emit(DismissEvent); @@ -1891,7 +1906,7 @@ impl RemoteServerProjects { return; }; let result = open_remote_project( - connection.into(), + Connection::DevContainer(dev_container_connection).into(), vec![starting_dir].into_iter().map(PathBuf::from).collect(), app_state, OpenOptions { diff --git a/crates/remote/src/transport/docker.rs b/crates/remote/src/transport/docker.rs index 74076b58e35bd1ea7759927bad255925e7f7d9b9..2b935e50fa49054a2668a71d30818fdd2fb57b1d 100644 --- a/crates/remote/src/transport/docker.rs +++ b/crates/remote/src/transport/docker.rs @@ -6,6 +6,7 @@ use collections::HashMap; use parking_lot::Mutex; use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; use semver::Version as SemanticVersion; +use std::collections::BTreeMap; use std::time::Instant; use std::{ path::{Path, PathBuf}, @@ -36,6 +37,7 @@ pub struct DockerConnectionOptions { pub remote_user: String, pub upload_binary_over_docker_exec: bool, pub use_podman: bool, + pub remote_env: BTreeMap, } pub(crate) struct DockerExecConnection { @@ -499,10 +501,14 @@ impl DockerExecConnection { args.push("-u".to_string()); args.push(self.connection_options.remote_user.clone()); + for (k, v) in self.connection_options.remote_env.iter() { + args.push("-e".to_string()); + args.push(format!("{k}={v}")); + } + for (k, v) in env.iter() { args.push("-e".to_string()); - let env_declaration = format!("{}={}", k, v); - args.push(env_declaration); + args.push(format!("{k}={v}")); } args.push(self.connection_options.container_id.clone()); @@ -632,6 +638,11 @@ impl RemoteConnection for DockerExecConnection { }; let mut docker_args = vec!["exec".to_string()]; + + for (k, v) in self.connection_options.remote_env.iter() { + docker_args.push("-e".to_string()); + docker_args.push(format!("{k}={v}")); + } for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] { if let Some(value) = std::env::var(env_var).ok() { docker_args.push("-e".to_string()); @@ -768,9 +779,14 @@ impl RemoteConnection for DockerExecConnection { docker_args.push(parsed_working_dir); } + for (k, v) in self.connection_options.remote_env.iter() { + docker_args.push("-e".to_string()); + docker_args.push(format!("{k}={v}")); + } + for (k, v) in env.iter() { docker_args.push("-e".to_string()); - docker_args.push(format!("{}={}", k, v)); + docker_args.push(format!("{k}={v}")); } match interactive { diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 6e0021d6c49d80628206151545476ffcd644516a..f8c64191dfe2602744e783f6d52484c45a7756d2 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -65,7 +65,8 @@ macro_rules! settings_overrides { } } } -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; +use std::hash::Hash; use std::sync::Arc; pub use util::serde::default_true; @@ -1023,6 +1024,8 @@ pub struct DevContainerConnection { pub remote_user: String, pub container_id: String, pub use_podman: bool, + pub extension_ids: Vec, + pub remote_env: BTreeMap, } #[with_fallible_options] diff --git a/crates/util/src/command.rs b/crates/util/src/command.rs index 44db592640bc70362b924ffca674fd02a4126f3a..a131d3c15b9fed351cc1d3a86bad7771b7d53167 100644 --- a/crates/util/src/command.rs +++ b/crates/util/src/command.rs @@ -68,6 +68,10 @@ impl Command { self } + pub fn get_args(&self) -> impl Iterator { + self.0.get_args() + } + pub fn env(&mut self, key: impl AsRef, val: impl AsRef) -> &mut Self { self.0.env(key, val); self @@ -129,4 +133,8 @@ impl Command { pub async fn status(&mut self) -> std::io::Result { self.0.status().await } + + pub fn get_program(&self) -> &OsStr { + self.0.get_program() + } } diff --git a/crates/util/src/command/darwin.rs b/crates/util/src/command/darwin.rs index 347fc8180ed9325d4f36a3fcce2f3c68964321d5..a3d7561f4e3cfde1f6ff33cdc469af071044fa0b 100644 --- a/crates/util/src/command/darwin.rs +++ b/crates/util/src/command/darwin.rs @@ -104,6 +104,10 @@ impl Command { self } + pub fn get_args(&self) -> impl Iterator { + self.args.iter().map(|s| s.as_os_str()) + } + pub fn env(&mut self, key: impl AsRef, val: impl AsRef) -> &mut Self { self.envs .insert(key.as_ref().to_owned(), Some(val.as_ref().to_owned())); @@ -217,6 +221,10 @@ impl Command { let mut child = self.spawn()?; child.status().await } + + pub fn get_program(&self) -> &OsStr { + self.program.as_os_str() + } } #[derive(Debug)] diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 334ad0925fd62a2ea529ed0e755d605924be266c..d38602ea768e8edc4f3de1ec439e67f0ee432a63 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -971,6 +971,9 @@ impl Domain for WorkspaceDb { sql!( ALTER TABLE remote_connections ADD COLUMN use_podman BOOLEAN; ), + sql!( + ALTER TABLE remote_connections ADD COLUMN remote_env TEXT; + ), ]; // Allow recovering from bad migration that was initially shipped to nightly @@ -1500,6 +1503,7 @@ impl WorkspaceDb { let mut name = None; let mut container_id = None; let mut use_podman = None; + let mut remote_env = None; match options { RemoteConnectionOptions::Ssh(options) => { kind = RemoteConnectionKind::Ssh; @@ -1518,6 +1522,7 @@ impl WorkspaceDb { name = Some(options.name); use_podman = Some(options.use_podman); user = Some(options.remote_user); + remote_env = serde_json::to_string(&options.remote_env).ok(); } #[cfg(any(test, feature = "test-support"))] RemoteConnectionOptions::Mock(options) => { @@ -1536,6 +1541,7 @@ impl WorkspaceDb { name, container_id, use_podman, + remote_env, ) } @@ -1549,6 +1555,7 @@ impl WorkspaceDb { name: Option, container_id: Option, use_podman: Option, + remote_env: Option, ) -> Result { if let Some(id) = this.select_row_bound(sql!( SELECT id @@ -1582,8 +1589,9 @@ impl WorkspaceDb { distro, name, container_id, - use_podman - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + use_podman, + remote_env + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) RETURNING id ))?(( kind.serialize(), @@ -1594,6 +1602,7 @@ impl WorkspaceDb { name, container_id, use_podman, + remote_env, ))? .context("failed to insert remote project")?; Ok(RemoteConnectionId(id)) @@ -1695,13 +1704,13 @@ impl WorkspaceDb { fn remote_connections(&self) -> Result> { Ok(self.select(sql!( SELECT - id, kind, host, port, user, distro, container_id, name, use_podman + id, kind, host, port, user, distro, container_id, name, use_podman, remote_env FROM remote_connections ))?()? .into_iter() .filter_map( - |(id, kind, host, port, user, distro, container_id, name, use_podman)| { + |(id, kind, host, port, user, distro, container_id, name, use_podman, remote_env)| { Some(( RemoteConnectionId(id), Self::remote_connection_from_row( @@ -1713,6 +1722,7 @@ impl WorkspaceDb { container_id, name, use_podman, + remote_env, )?, )) }, @@ -1724,9 +1734,9 @@ impl WorkspaceDb { &self, id: RemoteConnectionId, ) -> Result { - let (kind, host, port, user, distro, container_id, name, use_podman) = + let (kind, host, port, user, distro, container_id, name, use_podman, remote_env) = self.select_row_bound(sql!( - SELECT kind, host, port, user, distro, container_id, name, use_podman + SELECT kind, host, port, user, distro, container_id, name, use_podman, remote_env FROM remote_connections WHERE id = ? ))?(id.0)? @@ -1740,6 +1750,7 @@ impl WorkspaceDb { container_id, name, use_podman, + remote_env, ) .context("invalid remote_connection row") } @@ -1753,6 +1764,7 @@ impl WorkspaceDb { container_id: Option, name: Option, use_podman: Option, + remote_env: Option, ) -> Option { match RemoteConnectionKind::deserialize(&kind)? { RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions { @@ -1766,12 +1778,15 @@ impl WorkspaceDb { ..Default::default() })), RemoteConnectionKind::Docker => { + let remote_env: BTreeMap = + serde_json::from_str(&remote_env?).ok()?; Some(RemoteConnectionOptions::Docker(DockerConnectionOptions { container_id: container_id?, name: name?, remote_user: user?, upload_binary_over_docker_exec: false, use_podman: use_podman?, + remote_env, })) } }