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, DockerComposeServicePort, 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<PathBuf>, config: DockerComposeConfig,} struct DevContainerManifest { http_client: Arc<dyn HttpClient>, fs: Arc<dyn Fs>, docker_client: Arc<dyn DockerClient>, command_runner: Arc<dyn CommandRunner>, raw_config: String, config: ConfigStatus, local_environment: HashMap<String, String>, local_project_directory: PathBuf, config_directory: PathBuf, file_name: String, root_image: Option<DockerInspect>, features_build_info: Option<FeaturesBuildInfo>, features: Vec<FeatureManifest>,}const DEFAULT_REMOTE_PROJECT_DIR: &str = "/workspaces/";impl DevContainerManifest { async fn new( context: &DevContainerContext, environment: HashMap<String, String>, docker_client: Arc<dyn DockerClient>, command_runner: Arc<dyn CommandRunner>, local_config: DevContainerConfig, local_project_path: &Path, ) -> Result<Self, DevContainerError> { 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<String, DevContainerError> { 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<String, String>, ) -> Result<HashMap<String, String>, 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<String, String> = 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<PathBuf> { 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<String, DevContainerError> { 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?; 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::FilesystemErrorLines 1–500 of 6835. Load more
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, DockerComposeServicePort, 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<PathBuf>, config: DockerComposeConfig, } struct DevContainerManifest { http_client: Arc<dyn HttpClient>, fs: Arc<dyn Fs>, docker_client: Arc<dyn DockerClient>, command_runner: Arc<dyn CommandRunner>, raw_config: String, config: ConfigStatus, local_environment: HashMap<String, String>, local_project_directory: PathBuf, config_directory: PathBuf, file_name: String, root_image: Option<DockerInspect>, features_build_info: Option<FeaturesBuildInfo>, features: Vec<FeatureManifest>, } const DEFAULT_REMOTE_PROJECT_DIR: &str = "/workspaces/"; impl DevContainerManifest { async fn new( context: &DevContainerContext, environment: HashMap<String, String>, docker_client: Arc<dyn DockerClient>, command_runner: Arc<dyn CommandRunner>, local_config: DevContainerConfig, local_project_path: &Path, ) -> Result<Self, DevContainerError> { 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<String, DevContainerError> { 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<String, String>, ) -> Result<HashMap<String, String>, 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<String, String> = 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<PathBuf> { 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<String, DevContainerError> { 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?; 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