Skip to repository content9782 lines · 416.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:34:26.436Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
devcontainer_manifest.rs
1use std::{
2 collections::HashMap,
3 fmt::Debug,
4 hash::{DefaultHasher, Hash, Hasher},
5 path::{Path, PathBuf},
6 sync::Arc,
7};
8
9use regex::Regex;
10
11use fs::Fs;
12use http_client::HttpClient;
13use util::{ResultExt, command::Command, normalize_path};
14
15use crate::{
16 DevContainerConfig, DevContainerContext,
17 command_json::{CommandRunner, DefaultCommandRunner},
18 devcontainer_api::{DevContainerError, DevContainerUp},
19 devcontainer_json::{
20 ContainerBuild, DevContainer, DevContainerBuildType, FeatureOptions, ForwardPort,
21 MountDefinition, deserialize_devcontainer_json, deserialize_devcontainer_json_from_value,
22 deserialize_devcontainer_json_to_value,
23 },
24 docker::{
25 Docker, DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild,
26 DockerComposeServicePort, DockerComposeVolume, DockerInspect, DockerPs,
27 },
28 features::{DevContainerFeatureJson, FeatureManifest, parse_oci_feature_ref},
29 get_oci_token,
30 oci::{TokenResponse, download_oci_tarball, get_oci_manifest},
31 safe_id_lower,
32};
33
34enum ConfigStatus {
35 Deserialized(DevContainer),
36 VariableParsed(DevContainer),
37}
38
39enum ComposeUpBehavior {
40 Resume,
41 Create,
42}
43
44#[derive(Debug, Clone, Eq, PartialEq, Default)]
45pub(crate) struct DockerComposeResources {
46 files: Vec<PathBuf>,
47 config: DockerComposeConfig,
48}
49
50struct DevContainerManifest {
51 http_client: Arc<dyn HttpClient>,
52 fs: Arc<dyn Fs>,
53 docker_client: Arc<dyn DockerClient>,
54 command_runner: Arc<dyn CommandRunner>,
55 raw_config: String,
56 config: ConfigStatus,
57 local_environment: HashMap<String, String>,
58 local_project_directory: PathBuf,
59 config_directory: PathBuf,
60 file_name: String,
61 root_image: Option<DockerInspect>,
62 features_build_info: Option<FeaturesBuildInfo>,
63 features: Vec<FeatureManifest>,
64}
65const DEFAULT_REMOTE_PROJECT_DIR: &str = "/workspaces";
66impl DevContainerManifest {
67 async fn new(
68 context: &DevContainerContext,
69 environment: HashMap<String, String>,
70 docker_client: Arc<dyn DockerClient>,
71 command_runner: Arc<dyn CommandRunner>,
72 local_config: DevContainerConfig,
73 local_project_path: &Path,
74 ) -> Result<Self, DevContainerError> {
75 let config_path = local_project_path.join(local_config.config_path.clone());
76 log::debug!("parsing devcontainer json found in {:?}", &config_path);
77 let devcontainer_contents = context.fs.load(&config_path).await.map_err(|e| {
78 log::error!("Unable to read devcontainer contents: {e}");
79 DevContainerError::DevContainerParseFailed
80 })?;
81
82 let devcontainer = deserialize_devcontainer_json(&devcontainer_contents)?;
83
84 let devcontainer_directory = config_path.parent().ok_or_else(|| {
85 log::error!("Dev container file should be in a directory");
86 DevContainerError::NotInValidProject
87 })?;
88 let file_name = config_path
89 .file_name()
90 .and_then(|f| f.to_str())
91 .ok_or_else(|| {
92 log::error!("Dev container file has no file name, or is invalid unicode");
93 DevContainerError::DevContainerParseFailed
94 })?;
95
96 Ok(Self {
97 fs: context.fs.clone(),
98 http_client: context.http_client.clone(),
99 docker_client,
100 command_runner,
101 raw_config: devcontainer_contents,
102 config: ConfigStatus::Deserialized(devcontainer),
103 local_project_directory: local_project_path.to_path_buf(),
104 local_environment: environment,
105 config_directory: devcontainer_directory.to_path_buf(),
106 file_name: file_name.to_string(),
107 root_image: None,
108 features_build_info: None,
109 features: Vec::new(),
110 })
111 }
112
113 fn devcontainer_id(&self) -> String {
114 let mut labels = self.identifying_labels();
115 labels.sort_by_key(|(key, _)| *key);
116
117 let mut hasher = DefaultHasher::new();
118 for (key, value) in &labels {
119 key.hash(&mut hasher);
120 value.hash(&mut hasher);
121 }
122
123 format!("{:016x}", hasher.finish())
124 }
125
126 fn identifying_labels(&self) -> Vec<(&str, String)> {
127 let labels = vec![
128 (
129 "devcontainer.local_folder",
130 normalize_label_path(&self.local_project_directory.display().to_string()),
131 ),
132 (
133 "devcontainer.config_file",
134 normalize_label_path(&self.config_file().display().to_string()),
135 ),
136 ];
137 labels
138 }
139
140 fn parse_nonremote_vars_for_content(
141 &self,
142 content: &str,
143 ) -> Result<serde_json_lenient::Value, DevContainerError> {
144 let mut value = deserialize_devcontainer_json_to_value(content)?;
145 self.substitute_nonremote_vars_in_value(&mut value)?;
146 Ok(value)
147 }
148
149 fn substitute_nonremote_vars_in_value(
150 &self,
151 value: &mut serde_json_lenient::Value,
152 ) -> Result<(), DevContainerError> {
153 let mut to_visit = vec![value];
154
155 while let Some(value) = to_visit.pop() {
156 use serde_json_lenient::Value;
157
158 match value {
159 Value::String(string) => {
160 *string = string
161 .replace("${devcontainerId}", &self.devcontainer_id())
162 .replace(
163 "${containerWorkspaceFolderBasename}",
164 &self.remote_workspace_base_name().unwrap_or_default(),
165 )
166 .replace(
167 "${localWorkspaceFolderBasename}",
168 &self.local_workspace_base_name()?,
169 )
170 .replace(
171 "${containerWorkspaceFolder}",
172 &self
173 .remote_workspace_folder()
174 .map(|path| path.display().to_string())
175 .unwrap_or_default()
176 .replace('\\', "/"),
177 )
178 .replace(
179 "${localWorkspaceFolder}",
180 &self.local_workspace_folder().replace('\\', "/"),
181 );
182 *string = Self::replace_environment_variables(
183 string,
184 "localEnv",
185 &self.local_environment,
186 );
187 }
188
189 Value::Array(array) => to_visit.extend(array.iter_mut()),
190 Value::Object(object) => to_visit.extend(object.values_mut()),
191
192 Value::Null | Value::Bool(_) | Value::Number(_) => {}
193 }
194 }
195
196 Ok(())
197 }
198
199 fn parse_nonremote_vars(&mut self) -> Result<(), DevContainerError> {
200 let replaced_content = self.parse_nonremote_vars_for_content(&self.raw_config)?;
201 let parsed_config = deserialize_devcontainer_json_from_value(replaced_content)?;
202
203 self.config = ConfigStatus::VariableParsed(parsed_config);
204
205 Ok(())
206 }
207
208 fn runtime_remote_env(
209 &self,
210 container_env: &HashMap<String, String>,
211 ) -> Result<HashMap<String, String>, DevContainerError> {
212 let mut merged_remote_env = container_env.clone();
213 // HOME is user-specific, and we will often not run as the image user
214 merged_remote_env.remove("HOME");
215 if let Some(mut remote_env) = self.dev_container().remote_env.clone() {
216 remote_env.values_mut().for_each(|value| {
217 *value = Self::replace_environment_variables(value, "containerEnv", &container_env)
218 });
219 for (k, v) in remote_env {
220 merged_remote_env.insert(k, v);
221 }
222 }
223 Ok(merged_remote_env)
224 }
225
226 fn replace_environment_variables(
227 mut orig: &str,
228 environment_source: &str,
229 environment: &HashMap<String, String>,
230 ) -> String {
231 let mut replaced = String::with_capacity(orig.len());
232 let prefix = format!("${{{environment_source}:");
233 while let Some(start) = orig.find(&prefix) {
234 let var_name_start = start + prefix.len();
235 let Some(end) = orig[var_name_start..].find('}') else {
236 // No closing `}` => malformed variable reference => paste as is.
237 break;
238 };
239 let end = var_name_start + end;
240
241 let (var_name_end, default_start) =
242 if let Some(var_name_end) = orig[var_name_start..end].find(':') {
243 let var_name_end = var_name_start + var_name_end;
244 (var_name_end, var_name_end + 1)
245 } else {
246 (end, end)
247 };
248
249 let var_name = &orig[var_name_start..var_name_end];
250 if var_name.is_empty() {
251 // Empty variable name => paste as is.
252 replaced.push_str(&orig[..end + 1]);
253 orig = &orig[end + 1..];
254 continue;
255 }
256 let default = &orig[default_start..end];
257
258 replaced.push_str(&orig[..start]);
259 replaced.push_str(
260 environment
261 .get(var_name)
262 .map(|value| value.as_str())
263 .unwrap_or(default),
264 );
265 orig = &orig[end + 1..];
266 }
267 replaced.push_str(orig);
268 replaced
269 }
270
271 fn config_file(&self) -> PathBuf {
272 self.config_directory.join(&self.file_name)
273 }
274
275 fn dev_container(&self) -> &DevContainer {
276 match &self.config {
277 ConfigStatus::Deserialized(dev_container) => dev_container,
278 ConfigStatus::VariableParsed(dev_container) => dev_container,
279 }
280 }
281
282 async fn dockerfile_location(&self) -> Option<PathBuf> {
283 let dev_container = self.dev_container();
284 match dev_container.build_type() {
285 DevContainerBuildType::Image(_) => None,
286 DevContainerBuildType::Dockerfile(build) => {
287 Some(self.config_directory.join(&build.dockerfile))
288 }
289 DevContainerBuildType::DockerCompose => {
290 let Ok(docker_compose_manifest) = self.docker_compose_manifest().await else {
291 return None;
292 };
293 let Ok((_, main_service)) = find_primary_service(&docker_compose_manifest, self)
294 else {
295 return None;
296 };
297 main_service.build.and_then(|b| {
298 let compose_file = docker_compose_manifest.files.first()?;
299 resolve_compose_dockerfile(
300 compose_file,
301 b.context.as_deref(),
302 b.dockerfile.as_deref()?,
303 )
304 })
305 }
306 DevContainerBuildType::None => None,
307 }
308 }
309
310 fn generate_features_image_tag(&self, dockerfile_build_path: String) -> String {
311 let mut hasher = DefaultHasher::new();
312 let prefix = match &self.dev_container().name {
313 Some(name) => &safe_id_lower(name),
314 None => "zed-dc",
315 };
316 let prefix = prefix.get(..6).unwrap_or(prefix);
317 let prefix = prefix.trim_matches(|c: char| !c.is_alphanumeric());
318
319 dockerfile_build_path.hash(&mut hasher);
320
321 let hash = hasher.finish();
322 format!("{}-{:x}-features", prefix, hash)
323 }
324
325 /// Gets the base image from the devcontainer with the following precedence:
326 /// - The devcontainer image if an image is specified
327 /// - The image sourced in the Dockerfile if a Dockerfile is specified
328 /// - The image sourced in the docker-compose main service, if one is specified
329 /// - The image sourced in the docker-compose main service dockerfile, if one is specified
330 /// If no such image is available, return an error
331 async fn get_base_image_from_config(&self) -> Result<String, DevContainerError> {
332 match self.dev_container().build_type() {
333 DevContainerBuildType::Image(image) => {
334 return Ok(image);
335 }
336 DevContainerBuildType::Dockerfile(build) => {
337 let dockerfile_contents = self.expanded_dockerfile_content().await?;
338 return image_from_dockerfile(dockerfile_contents, &build.target).ok_or_else(
339 || {
340 log::error!("Unable to find base image in Dockerfile");
341 DevContainerError::DevContainerParseFailed
342 },
343 );
344 }
345 DevContainerBuildType::DockerCompose => {
346 let docker_compose_manifest = self.docker_compose_manifest().await?;
347 let (_, main_service) = find_primary_service(&docker_compose_manifest, &self)?;
348
349 if let Some(_) = main_service
350 .build
351 .as_ref()
352 .and_then(|b| b.dockerfile.as_ref())
353 {
354 let dockerfile_contents = self.expanded_dockerfile_content().await?;
355 return image_from_dockerfile(
356 dockerfile_contents,
357 &main_service.build.as_ref().and_then(|b| b.target.clone()),
358 )
359 .ok_or_else(|| {
360 log::error!("Unable to find base image in Dockerfile");
361 DevContainerError::DevContainerParseFailed
362 });
363 }
364 if let Some(image) = &main_service.image {
365 return Ok(image.to_string());
366 }
367
368 log::error!("No valid base image found in docker-compose configuration");
369 return Err(DevContainerError::DevContainerParseFailed);
370 }
371 DevContainerBuildType::None => {
372 log::error!("Not a valid devcontainer config for build");
373 return Err(DevContainerError::NotInValidProject);
374 }
375 }
376 }
377
378 async fn copy_local_feature(
379 &self,
380 feature_ref: &str,
381 destination: &Path,
382 ) -> Result<(), DevContainerError> {
383 let source_path = normalize_path(&self.config_directory.join(feature_ref));
384
385 if !self.fs.is_dir(&source_path).await {
386 log::error!(
387 "Local feature directory '{}' not found at {:?}",
388 feature_ref,
389 source_path
390 );
391 return Err(DevContainerError::ResourceFetchFailed);
392 }
393
394 let items = fs::read_dir_items(&*self.fs, &source_path)
395 .await
396 .map_err(|e| {
397 log::error!(
398 "Failed to read local feature directory {:?}: {e}",
399 source_path
400 );
401 DevContainerError::FilesystemError
402 })?;
403
404 for (item_path, is_dir) in &items {
405 let relative = item_path.strip_prefix(&source_path).map_err(|e| {
406 log::error!("Failed to compute relative path for {:?}: {e}", item_path);
407 DevContainerError::FilesystemError
408 })?;
409 let dest_path = destination.join(relative);
410
411 if *is_dir {
412 self.fs.create_dir(&dest_path).await.map_err(|e| {
413 log::error!("Failed to create directory {:?}: {e}", dest_path);
414 DevContainerError::FilesystemError
415 })?;
416 } else {
417 let content = self.fs.load_bytes(item_path).await.map_err(|e| {
418 log::error!("Failed to read file {:?}: {e}", item_path);
419 DevContainerError::FilesystemError
420 })?;
421 self.fs.write(&dest_path, &content).await.map_err(|e| {
422 log::error!("Failed to write file {:?}: {e}", dest_path);
423 DevContainerError::FilesystemError
424 })?;
425 }
426 }
427
428 Ok(())
429 }
430
431 async fn download_feature_and_dockerfile_resources(&mut self) -> Result<(), DevContainerError> {
432 let dev_container = match &self.config {
433 ConfigStatus::Deserialized(_) => {
434 log::error!(
435 "Dev container has not yet been parsed for variable expansion. Cannot yet download resources"
436 );
437 return Err(DevContainerError::DevContainerParseFailed);
438 }
439 ConfigStatus::VariableParsed(dev_container) => dev_container,
440 };
441 let root_image_tag = self.get_base_image_from_config().await?;
442 let root_image = self.docker_client.inspect(&root_image_tag).await?;
443
444 let temp_base = std::env::temp_dir().join("devcontainer-zed");
445 let timestamp = std::time::SystemTime::now()
446 .duration_since(std::time::UNIX_EPOCH)
447 .map(|d| d.as_millis())
448 .unwrap_or(0);
449
450 let features_content_dir = temp_base.join(format!("container-features-{}", timestamp));
451 let empty_context_dir = temp_base.join("empty-folder");
452
453 self.fs
454 .create_dir(&features_content_dir)
455 .await
456 .map_err(|e| {
457 log::error!("Failed to create features content dir: {e}");
458 DevContainerError::FilesystemError
459 })?;
460
461 self.fs.create_dir(&empty_context_dir).await.map_err(|e| {
462 log::error!("Failed to create empty context dir: {e}");
463 DevContainerError::FilesystemError
464 })?;
465
466 let dockerfile_path = features_content_dir.join("Dockerfile.extended");
467 let image_tag =
468 self.generate_features_image_tag(dockerfile_path.clone().display().to_string());
469
470 let build_info = FeaturesBuildInfo {
471 dockerfile_path,
472 features_content_dir,
473 empty_context_dir,
474 build_image: dev_container.image.clone(),
475 image_tag,
476 };
477
478 let features = match &dev_container.features {
479 Some(features) => features,
480 None => &HashMap::new(),
481 };
482
483 let container_user = get_container_user_from_config(&root_image, self)?;
484 let remote_user = get_remote_user_from_config(&root_image, self)?;
485
486 let builtin_env_content = format!(
487 "_CONTAINER_USER={}\n_REMOTE_USER={}\n",
488 container_user, remote_user
489 );
490
491 let builtin_env_path = build_info
492 .features_content_dir
493 .join("devcontainer-features.builtin.env");
494
495 self.fs
496 .write(&builtin_env_path, &builtin_env_content.as_bytes())
497 .await
498 .map_err(|e| {
499 log::error!("Failed to write builtin env file: {e}");
500 DevContainerError::FilesystemError
501 })?;
502
503 let ordered_features =
504 resolve_feature_order(features, &dev_container.override_feature_install_order);
505
506 for (index, (feature_ref, options)) in ordered_features.iter().enumerate() {
507 if matches!(options, FeatureOptions::Bool(false)) {
508 log::debug!(
509 "Feature '{}' is disabled (set to false), skipping",
510 feature_ref
511 );
512 continue;
513 }
514
515 let feature_id = extract_feature_id(feature_ref);
516 let consecutive_id = format!("{}_{}", feature_id, index);
517 let feature_dir = build_info.features_content_dir.join(&consecutive_id);
518
519 self.fs.create_dir(&feature_dir).await.map_err(|e| {
520 log::error!(
521 "Failed to create feature directory for {}: {e}",
522 feature_ref
523 );
524 DevContainerError::FilesystemError
525 })?;
526
527 if is_local_feature_ref(feature_ref) {
528 self.copy_local_feature(feature_ref, &feature_dir).await?;
529 } else {
530 let oci_ref = parse_oci_feature_ref(feature_ref).ok_or_else(|| {
531 log::error!(
532 "Feature '{}' is not a supported OCI feature reference",
533 feature_ref
534 );
535 DevContainerError::DevContainerParseFailed
536 })?;
537 let TokenResponse { token } =
538 get_oci_token(&oci_ref.registry, &oci_ref.path, &self.http_client)
539 .await
540 .map_err(|e| {
541 log::error!(
542 "Failed to get OCI token for feature '{}': {e}",
543 feature_ref
544 );
545 DevContainerError::ResourceFetchFailed
546 })?;
547 let manifest = get_oci_manifest(
548 &oci_ref.registry,
549 &oci_ref.path,
550 &token,
551 &self.http_client,
552 &oci_ref.version,
553 None,
554 )
555 .await
556 .map_err(|e| {
557 log::error!(
558 "Failed to fetch OCI manifest for feature '{}': {e}",
559 feature_ref
560 );
561 DevContainerError::ResourceFetchFailed
562 })?;
563 let digest = &manifest
564 .layers
565 .first()
566 .ok_or_else(|| {
567 log::error!(
568 "OCI manifest for feature '{}' contains no layers",
569 feature_ref
570 );
571 DevContainerError::ResourceFetchFailed
572 })?
573 .digest;
574 download_oci_tarball(
575 &token,
576 &oci_ref.registry,
577 &oci_ref.path,
578 digest,
579 "application/vnd.devcontainers.layer.v1+tar",
580 &feature_dir,
581 &self.http_client,
582 &self.fs,
583 None,
584 )
585 .await?;
586 }
587
588 let feature_json_path = &feature_dir.join("devcontainer-feature.json");
589 if !self.fs.is_file(feature_json_path).await {
590 let message = format!(
591 "No devcontainer-feature.json found in {:?}, no defaults to apply",
592 feature_json_path
593 );
594 log::error!("{}", &message);
595 return Err(DevContainerError::ResourceFetchFailed);
596 }
597
598 let contents = self.fs.load(&feature_json_path).await.map_err(|e| {
599 log::error!("error reading devcontainer-feature.json: {:?}", e);
600 DevContainerError::FilesystemError
601 })?;
602
603 let contents_parsed = self.parse_nonremote_vars_for_content(&contents)?;
604
605 let feature_json: DevContainerFeatureJson =
606 serde_json_lenient::from_value(contents_parsed).map_err(|e| {
607 log::error!("Failed to parse devcontainer-feature.json: {e}");
608 DevContainerError::ResourceFetchFailed
609 })?;
610
611 let feature_manifest = FeatureManifest::new(
612 consecutive_id,
613 feature_ref.to_string(),
614 feature_dir,
615 feature_json,
616 );
617
618 log::debug!("Prepared feature content for '{}'", feature_ref);
619
620 let env_content = feature_manifest
621 .write_feature_env(&self.fs, options)
622 .await?;
623
624 let wrapper_content = generate_install_wrapper(feature_ref, feature_id, &env_content)?;
625
626 self.fs
627 .write(
628 &feature_manifest
629 .file_path()
630 .join("devcontainer-features-install.sh"),
631 &wrapper_content.as_bytes(),
632 )
633 .await
634 .map_err(|e| {
635 log::error!("Failed to write install wrapper for {}: {e}", feature_ref);
636 DevContainerError::FilesystemError
637 })?;
638
639 self.features.push(feature_manifest);
640 }
641
642 // --- Phase 3: Generate extended Dockerfile from the inflated manifests ---
643
644 let is_compose = match dev_container.build_type() {
645 DevContainerBuildType::DockerCompose => true,
646 _ => false,
647 };
648 let use_buildkit = self.docker_client.supports_compose_buildkit() || !is_compose;
649
650 let dockerfile_base_content = if let Some(location) = &self.dockerfile_location().await {
651 self.fs.load(location).await.log_err()
652 } else {
653 None
654 };
655
656 let build_target = if is_compose {
657 find_primary_service(&self.docker_compose_manifest().await?, self)?
658 .1
659 .build
660 .and_then(|b| b.target)
661 } else {
662 dev_container.build.as_ref().and_then(|b| b.target.clone())
663 };
664
665 let dockerfile_content = dockerfile_base_content
666 .map(|content| {
667 dockerfile_inject_alias(
668 &content,
669 "dev_container_auto_added_stage_label",
670 build_target,
671 )
672 })
673 .unwrap_or_default();
674
675 let dockerfile_content = self.generate_dockerfile_extended(
676 &container_user,
677 &remote_user,
678 dockerfile_content,
679 use_buildkit,
680 );
681
682 self.fs
683 .write(&build_info.dockerfile_path, &dockerfile_content.as_bytes())
684 .await
685 .map_err(|e| {
686 log::error!("Failed to write Dockerfile.extended: {e}");
687 DevContainerError::FilesystemError
688 })?;
689
690 log::debug!(
691 "Features build resources written to {:?}",
692 build_info.features_content_dir
693 );
694
695 self.root_image = Some(root_image);
696 self.features_build_info = Some(build_info);
697
698 Ok(())
699 }
700
701 fn generate_dockerfile_extended(
702 &self,
703 container_user: &str,
704 remote_user: &str,
705 dockerfile_content: String,
706 use_buildkit: bool,
707 ) -> String {
708 #[cfg(not(target_os = "windows"))]
709 let update_remote_user_uid = self.dev_container().update_remote_user_uid.unwrap_or(true);
710 #[cfg(target_os = "windows")]
711 let update_remote_user_uid = false;
712 let feature_layers: String = self
713 .features
714 .iter()
715 .map(|manifest| {
716 manifest.generate_dockerfile_feature_layer(
717 use_buildkit,
718 FEATURES_CONTAINER_TEMP_DEST_FOLDER,
719 )
720 })
721 .collect();
722
723 let container_home_cmd = get_ent_passwd_shell_command(container_user);
724 let remote_home_cmd = get_ent_passwd_shell_command(remote_user);
725
726 let dest = FEATURES_CONTAINER_TEMP_DEST_FOLDER;
727
728 let feature_content_source_stage = if use_buildkit {
729 "".to_string()
730 } else {
731 "\nFROM dev_container_feature_content_temp as dev_containers_feature_content_source\n"
732 .to_string()
733 };
734
735 let builtin_env_source_path = if use_buildkit {
736 "./devcontainer-features.builtin.env"
737 } else {
738 "/tmp/build-features/devcontainer-features.builtin.env"
739 };
740
741 let mut extended_dockerfile = format!(
742 r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
743
744{dockerfile_content}
745{feature_content_source_stage}
746FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize
747USER root
748COPY --from=dev_containers_feature_content_source {builtin_env_source_path} /tmp/build-features/
749RUN chmod -R 0755 /tmp/build-features/
750
751FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage
752
753USER root
754
755RUN mkdir -p {dest}
756COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ {dest}
757
758RUN \
759echo "_CONTAINER_USER_HOME=$({container_home_cmd} | cut -d: -f6)" >> {dest}/devcontainer-features.builtin.env && \
760echo "_REMOTE_USER_HOME=$({remote_home_cmd} | cut -d: -f6)" >> {dest}/devcontainer-features.builtin.env
761
762{feature_layers}
763
764ARG _DEV_CONTAINERS_IMAGE_USER=root
765USER $_DEV_CONTAINERS_IMAGE_USER
766"#
767 );
768
769 // If we're not adding a uid update layer, then we should add env vars to this layer instead
770 if !update_remote_user_uid {
771 extended_dockerfile = format!(
772 r#"{extended_dockerfile}
773# Ensure that /etc/profile does not clobber the existing path
774RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true
775"#
776 );
777
778 for feature in &self.features {
779 let container_env_layer = feature.generate_dockerfile_env();
780 extended_dockerfile = format!("{extended_dockerfile}\n{container_env_layer}");
781 }
782
783 if let Some(env) = &self.dev_container().container_env {
784 for (key, value) in env {
785 extended_dockerfile = format!("{extended_dockerfile}ENV {key}={value}\n");
786 }
787 }
788 }
789
790 extended_dockerfile
791 }
792
793 fn build_merged_resources(
794 &self,
795 base_image: DockerInspect,
796 image_tag: &str,
797 ) -> Result<DockerBuildResources, DevContainerError> {
798 let dev_container = match &self.config {
799 ConfigStatus::Deserialized(_) => {
800 log::error!(
801 "Dev container has not yet been parsed for variable expansion. Cannot yet merge resources"
802 );
803 return Err(DevContainerError::DevContainerParseFailed);
804 }
805 ConfigStatus::VariableParsed(dev_container) => dev_container,
806 };
807 let mut mounts = Vec::new();
808 let mut cap_add = Vec::new();
809 let mut security_opt = Vec::new();
810 let mut privileged = false;
811 let mut init = false;
812 let mut metadata_entries = base_image
813 .config
814 .labels
815 .metadata
816 .clone()
817 .unwrap_or_default();
818 for entry in &mut metadata_entries {
819 for value in entry.values_mut() {
820 self.substitute_nonremote_vars_in_value(value)?;
821 }
822 }
823
824 for entry in &metadata_entries {
825 if let Some(serde_json_lenient::Value::Array(metadata_mounts)) = entry.get("mounts") {
826 for mount in metadata_mounts {
827 if let Some(mount) = mount_definition_from_metadata(mount) {
828 append_mount_with_target_override(&mut mounts, mount);
829 }
830 }
831 }
832 if entry.get("privileged").and_then(|value| value.as_bool()) == Some(true) {
833 privileged = true;
834 }
835 if entry.get("init").and_then(|value| value.as_bool()) == Some(true) {
836 init = true;
837 }
838 if let Some(serde_json_lenient::Value::Array(caps)) = entry.get("capAdd") {
839 for cap in caps.iter().filter_map(|cap| cap.as_str()) {
840 push_unique_string(&mut cap_add, cap);
841 }
842 }
843 if let Some(serde_json_lenient::Value::Array(opts)) = entry.get("securityOpt") {
844 for opt in opts.iter().filter_map(|opt| opt.as_str()) {
845 push_unique_string(&mut security_opt, opt);
846 }
847 }
848 }
849
850 for feature in &self.features {
851 for mount in feature.mounts() {
852 append_mount_with_target_override(&mut mounts, mount);
853 }
854 if feature.privileged() {
855 privileged = true;
856 }
857 if feature.init() {
858 init = true;
859 }
860 for cap in feature.cap_add() {
861 push_unique_string(&mut cap_add, &cap);
862 }
863 for opt in feature.security_opt() {
864 push_unique_string(&mut security_opt, &opt);
865 }
866 }
867
868 for mount in dev_container.mounts.clone().unwrap_or_default() {
869 append_mount_with_target_override(&mut mounts, mount);
870 }
871 privileged |= dev_container.privileged.unwrap_or(false);
872 init |= dev_container.init.unwrap_or(false);
873 for cap in dev_container.cap_add.clone().unwrap_or_default() {
874 push_unique_string(&mut cap_add, &cap);
875 }
876 for opt in dev_container.security_opt.clone().unwrap_or_default() {
877 push_unique_string(&mut security_opt, &opt);
878 }
879
880 let entrypoint_script = if dev_container.override_command == Some(false) {
881 None
882 } else {
883 let mut entrypoint_script_lines = vec![
884 "echo Container started".to_string(),
885 "trap \"exit 0\" 15".to_string(),
886 ];
887
888 for entrypoint in self.features.iter().filter_map(|f| f.entrypoint()) {
889 entrypoint_script_lines.push(entrypoint.clone());
890 }
891 entrypoint_script_lines.append(&mut vec![
892 "exec \"$@\"".to_string(),
893 "while sleep 1 & wait $!; do :; done".to_string(),
894 ]);
895
896 Some(entrypoint_script_lines.join("\n").trim().to_string())
897 };
898
899 let mut container_env = HashMap::new();
900 for entry in &metadata_entries {
901 if let Some(serde_json_lenient::Value::Object(env_map)) = entry.get("containerEnv") {
902 for (k, v) in env_map {
903 if let Some(s) = v.as_str() {
904 container_env.insert(k.clone(), s.to_string());
905 }
906 }
907 }
908 }
909 if let Some(config_env) = &dev_container.container_env {
910 for (k, v) in config_env {
911 container_env.insert(k.clone(), v.clone());
912 }
913 }
914
915 Ok(DockerBuildResources {
916 image: base_image,
917 image_tag: image_tag.to_string(),
918 additional_mounts: mounts,
919 container_env,
920 privileged,
921 init,
922 cap_add,
923 security_opt,
924 entrypoint_script,
925 })
926 }
927
928 async fn build_resources(&self) -> Result<DevContainerBuildResources, DevContainerError> {
929 if let ConfigStatus::Deserialized(_) = &self.config {
930 log::error!(
931 "Dev container has not yet been parsed for variable expansion. Cannot yet build resources"
932 );
933 return Err(DevContainerError::DevContainerParseFailed);
934 }
935 let dev_container = self.dev_container();
936 match dev_container.build_type() {
937 DevContainerBuildType::Image(base_image) => {
938 let built_docker_image = self.build_docker_image().await?;
939 let has_features = dev_container
940 .features
941 .as_ref()
942 .is_some_and(|features| !features.is_empty());
943 let features_image_tag = self
944 .features_build_info
945 .as_ref()
946 .map(|info| info.image_tag.as_str());
947 let uid_base_image = if has_features {
948 features_image_tag.unwrap_or(&base_image)
949 } else {
950 &base_image
951 };
952 let will_update_remote_user_uid =
953 self.should_update_remote_user_uid(&built_docker_image)?;
954
955 let built_docker_image = self
956 .update_remote_user_uid(built_docker_image, uid_base_image)
957 .await?;
958
959 let image_tag = if has_features || will_update_remote_user_uid {
960 features_image_tag.unwrap_or(&base_image)
961 } else {
962 &base_image
963 };
964 let resources = self.build_merged_resources(built_docker_image, image_tag)?;
965 Ok(DevContainerBuildResources::Docker(resources))
966 }
967 DevContainerBuildType::Dockerfile(_) => {
968 let built_docker_image = self.build_docker_image().await?;
969 let Some(features_build_info) = &self.features_build_info else {
970 log::error!(
971 "Can't attempt to build update UID dockerfile before initial docker build"
972 );
973 return Err(DevContainerError::DevContainerParseFailed);
974 };
975 let built_docker_image = self
976 .update_remote_user_uid(built_docker_image, &features_build_info.image_tag)
977 .await?;
978
979 let resources = self
980 .build_merged_resources(built_docker_image, &features_build_info.image_tag)?;
981 Ok(DevContainerBuildResources::Docker(resources))
982 }
983 DevContainerBuildType::DockerCompose => {
984 log::debug!("Using docker compose. Building extended compose files");
985 let docker_compose_resources = self.build_and_extend_compose_files().await?;
986
987 return Ok(DevContainerBuildResources::DockerCompose(
988 docker_compose_resources,
989 ));
990 }
991 DevContainerBuildType::None => {
992 return Err(DevContainerError::DevContainerParseFailed);
993 }
994 }
995 }
996
997 async fn run_dev_container(
998 &self,
999 build_resources: DevContainerBuildResources,
1000 ) -> Result<DevContainerUp, DevContainerError> {
1001 let ConfigStatus::VariableParsed(_) = &self.config else {
1002 log::error!(
1003 "Variables have not been parsed; cannot proceed with running the dev container"
1004 );
1005 return Err(DevContainerError::DevContainerParseFailed);
1006 };
1007 let running_container = match build_resources {
1008 DevContainerBuildResources::DockerCompose(resources) => {
1009 self.run_docker_compose(resources).await?
1010 }
1011 DevContainerBuildResources::Docker(resources) => {
1012 self.run_docker_image(resources).await?
1013 }
1014 };
1015
1016 let remote_user = get_remote_user_from_config(&running_container, self)?;
1017 let remote_workspace_folder = self.remote_workspace_folder()?;
1018
1019 let remote_env = self.runtime_remote_env(&running_container.config.env_as_map()?)?;
1020
1021 Ok(DevContainerUp {
1022 started_at: running_container
1023 .state
1024 .as_ref()
1025 .and_then(|state| state.started_at.clone()),
1026 container_id: running_container.id,
1027 remote_user,
1028 remote_workspace_folder: remote_workspace_folder.display().to_string(),
1029 extension_ids: self.extension_ids(),
1030 remote_env,
1031 })
1032 }
1033
1034 async fn docker_compose_manifest(&self) -> Result<DockerComposeResources, DevContainerError> {
1035 let dev_container = match &self.config {
1036 ConfigStatus::Deserialized(_) => {
1037 log::error!(
1038 "Dev container has not yet been parsed for variable expansion. Cannot yet get docker compose files"
1039 );
1040 return Err(DevContainerError::DevContainerParseFailed);
1041 }
1042 ConfigStatus::VariableParsed(dev_container) => dev_container,
1043 };
1044 let Some(docker_compose_files) = dev_container.docker_compose_file.clone() else {
1045 return Err(DevContainerError::DevContainerParseFailed);
1046 };
1047 // Normalize upfront so every downstream consumer of
1048 // `DockerComposeResources.files` (compose fragment reads, project-name
1049 // derivation, `docker compose -f` invocations, …) sees resolved paths.
1050 // `dockerComposeFile` entries are joined verbatim with
1051 // `config_directory`, so raw entries can carry `..` components.
1052 let docker_compose_full_paths = docker_compose_files
1053 .iter()
1054 .map(|relative| normalize_path(&self.config_directory.join(relative)))
1055 .collect::<Vec<PathBuf>>();
1056
1057 let Some(config) = self
1058 .docker_client
1059 .get_docker_compose_config(&docker_compose_full_paths)
1060 .await?
1061 else {
1062 log::error!("Output could not deserialize into DockerComposeConfig");
1063 return Err(DevContainerError::DevContainerParseFailed);
1064 };
1065 Ok(DockerComposeResources {
1066 files: docker_compose_full_paths,
1067 config,
1068 })
1069 }
1070
1071 async fn build_and_extend_compose_files(
1072 &self,
1073 ) -> Result<DockerComposeResources, DevContainerError> {
1074 let dev_container = match &self.config {
1075 ConfigStatus::Deserialized(_) => {
1076 log::error!(
1077 "Dev container has not yet been parsed for variable expansion. Cannot yet build from compose files"
1078 );
1079 return Err(DevContainerError::DevContainerParseFailed);
1080 }
1081 ConfigStatus::VariableParsed(dev_container) => dev_container,
1082 };
1083
1084 let Some(features_build_info) = &self.features_build_info else {
1085 log::error!(
1086 "Cannot build and extend compose files: features build info is not yet constructed"
1087 );
1088 return Err(DevContainerError::DevContainerParseFailed);
1089 };
1090 let mut docker_compose_resources = self.docker_compose_manifest().await?;
1091 let supports_buildkit = self.docker_client.supports_compose_buildkit();
1092
1093 let (main_service_name, main_service) =
1094 find_primary_service(&docker_compose_resources, self)?;
1095 let (built_service_image, built_service_image_tag) = if main_service
1096 .build
1097 .as_ref()
1098 .map(|b| b.dockerfile.as_ref())
1099 .is_some()
1100 {
1101 if !supports_buildkit {
1102 self.build_feature_content_image().await?;
1103 }
1104
1105 let dockerfile_path = &features_build_info.dockerfile_path;
1106
1107 let build_args = if !supports_buildkit {
1108 HashMap::from([
1109 (
1110 "_DEV_CONTAINERS_BASE_IMAGE".to_string(),
1111 "dev_container_auto_added_stage_label".to_string(),
1112 ),
1113 ("_DEV_CONTAINERS_IMAGE_USER".to_string(), "root".to_string()),
1114 ])
1115 } else {
1116 HashMap::from([
1117 ("BUILDKIT_INLINE_CACHE".to_string(), "1".to_string()),
1118 (
1119 "_DEV_CONTAINERS_BASE_IMAGE".to_string(),
1120 "dev_container_auto_added_stage_label".to_string(),
1121 ),
1122 ("_DEV_CONTAINERS_IMAGE_USER".to_string(), "root".to_string()),
1123 ])
1124 };
1125
1126 let additional_contexts = if !supports_buildkit {
1127 None
1128 } else {
1129 Some(HashMap::from([(
1130 "dev_containers_feature_content_source".to_string(),
1131 features_build_info
1132 .features_content_dir
1133 .display()
1134 .to_string(),
1135 )]))
1136 };
1137
1138 let build_override = DockerComposeConfig {
1139 name: None,
1140 services: HashMap::from([(
1141 main_service_name.clone(),
1142 DockerComposeService {
1143 image: Some(features_build_info.image_tag.clone()),
1144 entrypoint: None,
1145 cap_add: None,
1146 security_opt: None,
1147 labels: None,
1148 build: Some(DockerComposeServiceBuild {
1149 context: Some(
1150 main_service
1151 .build
1152 .as_ref()
1153 .and_then(|b| b.context.clone())
1154 .unwrap_or_else(|| {
1155 features_build_info.empty_context_dir.display().to_string()
1156 }),
1157 ),
1158 dockerfile: Some(dockerfile_path.display().to_string()),
1159 target: Some("dev_containers_target_stage".to_string()),
1160 args: Some(build_args),
1161 additional_contexts,
1162 }),
1163 volumes: Vec::new(),
1164 ..Default::default()
1165 },
1166 )]),
1167 volumes: HashMap::new(),
1168 };
1169
1170 let temp_base = std::env::temp_dir().join("devcontainer-zed");
1171 let config_location = temp_base.join("docker_compose_build.json");
1172
1173 let config_json = serde_json_lenient::to_string(&build_override).map_err(|e| {
1174 log::error!("Error serializing docker compose runtime override: {e}");
1175 DevContainerError::DevContainerParseFailed
1176 })?;
1177
1178 self.fs
1179 .write(&config_location, config_json.as_bytes())
1180 .await
1181 .map_err(|e| {
1182 log::error!("Error writing the runtime override file: {e}");
1183 DevContainerError::FilesystemError
1184 })?;
1185
1186 docker_compose_resources.files.push(config_location);
1187
1188 let project_name = self.project_name().await?;
1189 let compose_services =
1190 compose_service_list(&main_service_name, dev_container.run_services.as_ref());
1191 self.docker_client
1192 .docker_compose_build(
1193 &docker_compose_resources.files,
1194 &project_name,
1195 compose_services.as_ref(),
1196 )
1197 .await?;
1198 (
1199 self.docker_client
1200 .inspect(&features_build_info.image_tag)
1201 .await?,
1202 &features_build_info.image_tag,
1203 )
1204 } else if let Some(image) = &main_service.image {
1205 if dev_container
1206 .features
1207 .as_ref()
1208 .is_none_or(|features| features.is_empty())
1209 {
1210 (self.docker_client.inspect(image).await?, image)
1211 } else {
1212 if !supports_buildkit {
1213 self.build_feature_content_image().await?;
1214 }
1215
1216 let dockerfile_path = &features_build_info.dockerfile_path;
1217
1218 let build_args = if !supports_buildkit {
1219 HashMap::from([
1220 ("_DEV_CONTAINERS_BASE_IMAGE".to_string(), image.clone()),
1221 ("_DEV_CONTAINERS_IMAGE_USER".to_string(), "root".to_string()),
1222 ])
1223 } else {
1224 HashMap::from([
1225 ("BUILDKIT_INLINE_CACHE".to_string(), "1".to_string()),
1226 ("_DEV_CONTAINERS_BASE_IMAGE".to_string(), image.clone()),
1227 ("_DEV_CONTAINERS_IMAGE_USER".to_string(), "root".to_string()),
1228 ])
1229 };
1230
1231 let additional_contexts = if !supports_buildkit {
1232 None
1233 } else {
1234 Some(HashMap::from([(
1235 "dev_containers_feature_content_source".to_string(),
1236 features_build_info
1237 .features_content_dir
1238 .display()
1239 .to_string(),
1240 )]))
1241 };
1242
1243 let build_override = DockerComposeConfig {
1244 name: None,
1245 services: HashMap::from([(
1246 main_service_name.clone(),
1247 DockerComposeService {
1248 image: Some(features_build_info.image_tag.clone()),
1249 entrypoint: None,
1250 cap_add: None,
1251 security_opt: None,
1252 labels: None,
1253 build: Some(DockerComposeServiceBuild {
1254 context: Some(
1255 features_build_info.empty_context_dir.display().to_string(),
1256 ),
1257 dockerfile: Some(dockerfile_path.display().to_string()),
1258 target: Some("dev_containers_target_stage".to_string()),
1259 args: Some(build_args),
1260 additional_contexts,
1261 }),
1262 volumes: Vec::new(),
1263 ..Default::default()
1264 },
1265 )]),
1266 volumes: HashMap::new(),
1267 };
1268
1269 let temp_base = std::env::temp_dir().join("devcontainer-zed");
1270 let config_location = temp_base.join("docker_compose_build.json");
1271
1272 let config_json = serde_json_lenient::to_string(&build_override).map_err(|e| {
1273 log::error!("Error serializing docker compose runtime override: {e}");
1274 DevContainerError::DevContainerParseFailed
1275 })?;
1276
1277 self.fs
1278 .write(&config_location, config_json.as_bytes())
1279 .await
1280 .map_err(|e| {
1281 log::error!("Error writing the runtime override file: {e}");
1282 DevContainerError::FilesystemError
1283 })?;
1284
1285 docker_compose_resources.files.push(config_location);
1286
1287 let project_name = self.project_name().await?;
1288 let compose_services =
1289 compose_service_list(&main_service_name, dev_container.run_services.as_ref());
1290 self.docker_client
1291 .docker_compose_build(
1292 &docker_compose_resources.files,
1293 &project_name,
1294 compose_services.as_ref(),
1295 )
1296 .await?;
1297
1298 (
1299 self.docker_client
1300 .inspect(&features_build_info.image_tag)
1301 .await?,
1302 &features_build_info.image_tag,
1303 )
1304 }
1305 } else {
1306 log::error!("Docker compose must have either image or dockerfile defined");
1307 return Err(DevContainerError::DevContainerParseFailed);
1308 };
1309
1310 let built_service_image = self
1311 .update_remote_user_uid(built_service_image, built_service_image_tag)
1312 .await?;
1313
1314 let resources =
1315 self.build_merged_resources(built_service_image, built_service_image_tag)?;
1316
1317 let network_mode = main_service.network_mode.as_ref();
1318 let network_mode_service = network_mode.and_then(|mode| mode.strip_prefix("service:"));
1319 let runtime_override_file = self
1320 .write_runtime_override_file(&main_service_name, network_mode_service, resources)
1321 .await?;
1322
1323 docker_compose_resources.files.push(runtime_override_file);
1324
1325 Ok(docker_compose_resources)
1326 }
1327
1328 async fn write_runtime_override_file(
1329 &self,
1330 main_service_name: &str,
1331 network_mode_service: Option<&str>,
1332 resources: DockerBuildResources,
1333 ) -> Result<PathBuf, DevContainerError> {
1334 let config =
1335 self.build_runtime_override(main_service_name, network_mode_service, resources)?;
1336 let temp_base = std::env::temp_dir().join("devcontainer-zed");
1337 let config_location = temp_base.join("docker_compose_runtime.json");
1338
1339 let config_json = serde_json_lenient::to_string(&config).map_err(|e| {
1340 log::error!("Error serializing docker compose runtime override: {e}");
1341 DevContainerError::DevContainerParseFailed
1342 })?;
1343
1344 self.fs
1345 .write(&config_location, config_json.as_bytes())
1346 .await
1347 .map_err(|e| {
1348 log::error!("Error writing the runtime override file: {e}");
1349 DevContainerError::FilesystemError
1350 })?;
1351
1352 Ok(config_location)
1353 }
1354
1355 fn build_runtime_override(
1356 &self,
1357 main_service_name: &str,
1358 network_mode_service: Option<&str>,
1359 resources: DockerBuildResources,
1360 ) -> Result<DockerComposeConfig, DevContainerError> {
1361 let mut runtime_labels = HashMap::new();
1362
1363 {
1364 let mut metadata_entries: Vec<serde_json_lenient::Value> = Vec::new();
1365
1366 if let Some(image_metadata) = &resources.image.config.labels.metadata {
1367 for entry in image_metadata {
1368 if let Ok(val) = serde_json_lenient::to_value(entry) {
1369 metadata_entries.push(val);
1370 }
1371 }
1372 }
1373
1374 for feature in &self.features {
1375 let entry = feature.build_metadata_entry();
1376 if !entry.is_empty() {
1377 metadata_entries.push(serde_json_lenient::Value::Object(entry));
1378 }
1379 }
1380
1381 if let Ok(substituted_json) = self.parse_nonremote_vars_for_content(&self.raw_config) {
1382 let config_entry = build_devcontainer_metadata_entry(&substituted_json);
1383 if !config_entry.is_empty() {
1384 metadata_entries.push(serde_json_lenient::Value::Object(config_entry));
1385 }
1386 }
1387
1388 if !metadata_entries.is_empty() {
1389 let serialized = serde_json_lenient::to_string(&metadata_entries).map_err(|e| {
1390 log::error!("Error serializing docker image metadata: {e}");
1391 DevContainerError::ContainerNotValid(resources.image.id.clone())
1392 })?;
1393 runtime_labels.insert(
1394 "devcontainer.metadata".to_string(),
1395 escape_compose_interpolation(&serialized),
1396 );
1397 }
1398 }
1399
1400 for (k, v) in self.identifying_labels() {
1401 runtime_labels.insert(k.to_string(), escape_compose_interpolation(&v));
1402 }
1403
1404 let config_volumes: HashMap<String, DockerComposeVolume> = resources
1405 .additional_mounts
1406 .iter()
1407 .filter_map(|mount| {
1408 if let Some(mount_type) = &mount.mount_type
1409 && mount_type.to_lowercase() == "volume"
1410 && let Some(source) = &mount.source
1411 {
1412 Some((
1413 source.clone(),
1414 DockerComposeVolume {
1415 name: Some(source.clone()),
1416 },
1417 ))
1418 } else {
1419 None
1420 }
1421 })
1422 .collect();
1423
1424 let volumes: Vec<MountDefinition> = resources
1425 .additional_mounts
1426 .iter()
1427 .map(|v| MountDefinition {
1428 source: v.source.clone(),
1429 target: v.target.clone(),
1430 mount_type: v.mount_type.clone(),
1431 })
1432 .collect();
1433
1434 let entrypoint = resources.entrypoint_script.map(|script| {
1435 vec![
1436 "/bin/sh".to_string(),
1437 "-c".to_string(),
1438 script,
1439 "-".to_string(),
1440 ]
1441 });
1442
1443 let cap_add = if resources.cap_add.is_empty() {
1444 None
1445 } else {
1446 Some(resources.cap_add)
1447 };
1448 let security_opt = if resources.security_opt.is_empty() {
1449 None
1450 } else {
1451 Some(resources.security_opt)
1452 };
1453
1454 let environment = if resources.container_env.is_empty() {
1455 None
1456 } else {
1457 Some(
1458 resources
1459 .container_env
1460 .into_iter()
1461 .map(|(key, value)| (key, escape_compose_interpolation(&value)))
1462 .collect(),
1463 )
1464 };
1465 let privileged = resources.privileged.then_some(true);
1466 let init = resources.init.then_some(true);
1467
1468 let mut main_service = DockerComposeService {
1469 entrypoint,
1470 cap_add,
1471 security_opt,
1472 labels: Some(runtime_labels),
1473 volumes,
1474 privileged,
1475 init,
1476 environment,
1477 ..Default::default()
1478 };
1479 // let mut extra_service_port_declarations: Vec<(String, DockerComposeService)> = Vec::new();
1480 let mut service_declarations: HashMap<String, DockerComposeService> = HashMap::new();
1481 if let Some(forward_ports) = &self.dev_container().forward_ports {
1482 let main_service_ports: Vec<String> = forward_ports
1483 .iter()
1484 .filter_map(|f| match f {
1485 ForwardPort::Number(port) => Some(port.to_string()),
1486 ForwardPort::String(port) => {
1487 let parts: Vec<&str> = port.split(":").collect();
1488 if parts.len() <= 1 {
1489 Some(port.to_string())
1490 } else if parts.len() == 2 {
1491 if parts[0] == main_service_name {
1492 Some(parts[1].to_string())
1493 } else {
1494 None
1495 }
1496 } else {
1497 None
1498 }
1499 }
1500 })
1501 .collect();
1502 for port in main_service_ports {
1503 // If the main service uses a different service's network bridge, append to that service's ports instead
1504 if let Some(network_service_name) = network_mode_service {
1505 if let Some(service) = service_declarations.get_mut(network_service_name) {
1506 service.ports.push(DockerComposeServicePort {
1507 target: port.clone(),
1508 published: port.clone(),
1509 ..Default::default()
1510 });
1511 } else {
1512 service_declarations.insert(
1513 network_service_name.to_string(),
1514 DockerComposeService {
1515 ports: vec![DockerComposeServicePort {
1516 target: port.clone(),
1517 published: port.clone(),
1518 ..Default::default()
1519 }],
1520 ..Default::default()
1521 },
1522 );
1523 }
1524 } else {
1525 main_service.ports.push(DockerComposeServicePort {
1526 target: port.clone(),
1527 published: port.clone(),
1528 ..Default::default()
1529 });
1530 }
1531 }
1532 let other_service_ports: Vec<(&str, &str)> = forward_ports
1533 .iter()
1534 .filter_map(|f| match f {
1535 ForwardPort::Number(_) => None,
1536 ForwardPort::String(port) => {
1537 let parts: Vec<&str> = port.split(":").collect();
1538 if parts.len() != 2 {
1539 None
1540 } else {
1541 if parts[0] == main_service_name {
1542 None
1543 } else {
1544 Some((parts[0], parts[1]))
1545 }
1546 }
1547 }
1548 })
1549 .collect();
1550 for (service_name, port) in other_service_ports {
1551 if let Some(service) = service_declarations.get_mut(service_name) {
1552 service.ports.push(DockerComposeServicePort {
1553 target: port.to_string(),
1554 published: port.to_string(),
1555 ..Default::default()
1556 });
1557 } else {
1558 service_declarations.insert(
1559 service_name.to_string(),
1560 DockerComposeService {
1561 ports: vec![DockerComposeServicePort {
1562 target: port.to_string(),
1563 published: port.to_string(),
1564 ..Default::default()
1565 }],
1566 ..Default::default()
1567 },
1568 );
1569 }
1570 }
1571 }
1572
1573 service_declarations.insert(main_service_name.to_string(), main_service);
1574 let new_docker_compose_config = DockerComposeConfig {
1575 name: None,
1576 services: service_declarations,
1577 volumes: config_volumes,
1578 };
1579
1580 Ok(new_docker_compose_config)
1581 }
1582
1583 async fn build_docker_image(&self) -> Result<DockerInspect, DevContainerError> {
1584 let dev_container = match &self.config {
1585 ConfigStatus::Deserialized(_) => {
1586 log::error!(
1587 "Dev container has not yet been parsed for variable expansion. Cannot yet build image"
1588 );
1589 return Err(DevContainerError::DevContainerParseFailed);
1590 }
1591 ConfigStatus::VariableParsed(dev_container) => dev_container,
1592 };
1593
1594 match dev_container.build_type() {
1595 DevContainerBuildType::Image(image_tag) => {
1596 let base_image = self.docker_client.inspect(&image_tag).await?;
1597 if dev_container
1598 .features
1599 .as_ref()
1600 .is_none_or(|features| features.is_empty())
1601 {
1602 log::debug!("No features to add. Using base image");
1603 return Ok(base_image);
1604 }
1605 }
1606 DevContainerBuildType::Dockerfile(_) => {}
1607 DevContainerBuildType::DockerCompose | DevContainerBuildType::None => {
1608 return Err(DevContainerError::DevContainerParseFailed);
1609 }
1610 };
1611
1612 let mut command = self.create_docker_build()?;
1613
1614 let output = self
1615 .command_runner
1616 .run_command(&mut command)
1617 .await
1618 .map_err(|e| {
1619 log::error!("Error building docker image: {e}");
1620 DevContainerError::CommandFailed(command.get_program().display().to_string())
1621 })?;
1622
1623 if !output.status.success() {
1624 let stderr = String::from_utf8_lossy(&output.stderr);
1625 log::error!("docker buildx build failed: {stderr}");
1626 return Err(DevContainerError::CommandFailed(
1627 command.get_program().display().to_string(),
1628 ));
1629 }
1630
1631 // After a successful build, inspect the newly tagged image to get its metadata
1632 let Some(features_build_info) = &self.features_build_info else {
1633 log::error!("Features build info expected, but not created");
1634 return Err(DevContainerError::DevContainerParseFailed);
1635 };
1636 let image = self
1637 .docker_client
1638 .inspect(&features_build_info.image_tag)
1639 .await?;
1640
1641 Ok(image)
1642 }
1643
1644 #[cfg(target_os = "windows")]
1645 fn should_update_remote_user_uid(
1646 &self,
1647 _image: &DockerInspect,
1648 ) -> Result<bool, DevContainerError> {
1649 Ok(false)
1650 }
1651
1652 #[cfg(target_os = "windows")]
1653 async fn update_remote_user_uid(
1654 &self,
1655 image: DockerInspect,
1656 _base_image: &str,
1657 ) -> Result<DockerInspect, DevContainerError> {
1658 Ok(image)
1659 }
1660
1661 #[cfg(not(target_os = "windows"))]
1662 fn should_update_remote_user_uid(
1663 &self,
1664 image: &DockerInspect,
1665 ) -> Result<bool, DevContainerError> {
1666 if self.features_build_info.is_none() {
1667 return Ok(false);
1668 }
1669 if self.dev_container().update_remote_user_uid == Some(false) {
1670 return Ok(false);
1671 }
1672 let remote_user = get_remote_user_from_config(image, self)?;
1673 Ok(remote_user != "root" && !remote_user.chars().all(|c| c.is_ascii_digit()))
1674 }
1675
1676 #[cfg(not(target_os = "windows"))]
1677 async fn update_remote_user_uid(
1678 &self,
1679 image: DockerInspect,
1680 base_image: &str,
1681 ) -> Result<DockerInspect, DevContainerError> {
1682 let Some(features_build_info) = &self.features_build_info else {
1683 return Ok(image);
1684 };
1685
1686 if !self.should_update_remote_user_uid(&image)? {
1687 return Ok(image);
1688 }
1689
1690 let remote_user = get_remote_user_from_config(&image, self)?;
1691
1692 let image_user = image
1693 .config
1694 .image_user
1695 .as_deref()
1696 .unwrap_or("root")
1697 .to_string();
1698
1699 let host_uid = Command::new("id")
1700 .arg("-u")
1701 .output()
1702 .await
1703 .map_err(|e| {
1704 log::error!("Failed to get host UID: {e}");
1705 DevContainerError::CommandFailed("id -u".to_string())
1706 })
1707 .and_then(|output| {
1708 String::from_utf8_lossy(&output.stdout)
1709 .trim()
1710 .parse::<u32>()
1711 .map_err(|e| {
1712 log::error!("Failed to parse host UID: {e}");
1713 DevContainerError::CommandFailed("id -u".to_string())
1714 })
1715 })?;
1716
1717 let host_gid = Command::new("id")
1718 .arg("-g")
1719 .output()
1720 .await
1721 .map_err(|e| {
1722 log::error!("Failed to get host GID: {e}");
1723 DevContainerError::CommandFailed("id -g".to_string())
1724 })
1725 .and_then(|output| {
1726 String::from_utf8_lossy(&output.stdout)
1727 .trim()
1728 .parse::<u32>()
1729 .map_err(|e| {
1730 log::error!("Failed to parse host GID: {e}");
1731 DevContainerError::CommandFailed("id -g".to_string())
1732 })
1733 })?;
1734
1735 let dockerfile_content = self.generate_update_uid_dockerfile();
1736
1737 let dockerfile_path = features_build_info
1738 .features_content_dir
1739 .join("updateUID.Dockerfile");
1740 self.fs
1741 .write(&dockerfile_path, dockerfile_content.as_bytes())
1742 .await
1743 .map_err(|e| {
1744 log::error!("Failed to write updateUID Dockerfile: {e}");
1745 DevContainerError::FilesystemError
1746 })?;
1747
1748 let updated_image_tag = features_build_info.image_tag.clone();
1749
1750 let mut command = Command::new(self.docker_client.docker_cli());
1751 // Without a usable BuildKit, force the classic builder: the build's
1752 // `FROM $BASE_IMAGE` references the locally-built features image, which
1753 // only resolves from the daemon's image store under the classic builder.
1754 if !self.docker_client.supports_compose_buildkit()
1755 && self.docker_client.docker_cli() != "podman"
1756 {
1757 command.env("DOCKER_BUILDKIT", "0");
1758 }
1759 command.args(["build"]);
1760 command.args(["-f", &dockerfile_path.display().to_string()]);
1761 command.args(["-t", &updated_image_tag]);
1762 command.args(["--build-arg", &format!("BASE_IMAGE={}", base_image)]);
1763 command.args(["--build-arg", &format!("REMOTE_USER={}", remote_user)]);
1764 command.args(["--build-arg", &format!("NEW_UID={}", host_uid)]);
1765 command.args(["--build-arg", &format!("NEW_GID={}", host_gid)]);
1766 command.args(["--build-arg", &format!("IMAGE_USER={}", image_user)]);
1767 command.arg(features_build_info.empty_context_dir.display().to_string());
1768
1769 let output = self
1770 .command_runner
1771 .run_command(&mut command)
1772 .await
1773 .map_err(|e| {
1774 log::error!("Error building UID update image: {e}");
1775 DevContainerError::CommandFailed(command.get_program().display().to_string())
1776 })?;
1777
1778 if !output.status.success() {
1779 let stderr = String::from_utf8_lossy(&output.stderr);
1780 log::error!("UID update build failed: {stderr}");
1781 return Err(DevContainerError::CommandFailed(
1782 command.get_program().display().to_string(),
1783 ));
1784 }
1785
1786 self.docker_client.inspect(&updated_image_tag).await
1787 }
1788
1789 #[cfg(not(target_os = "windows"))]
1790 fn generate_update_uid_dockerfile(&self) -> String {
1791 let mut dockerfile = r#"ARG BASE_IMAGE
1792FROM $BASE_IMAGE
1793
1794USER root
1795
1796ARG REMOTE_USER
1797ARG NEW_UID
1798ARG NEW_GID
1799SHELL ["/bin/sh", "-c"]
1800RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/OLD_UID=\1;OLD_GID=\2;HOME_FOLDER=\3/p" /etc/passwd); \
1801 eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_UID}:.*/EXISTING_USER=\1/p" /etc/passwd); \
1802 eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_GID}:.*/EXISTING_GROUP=\1/p" /etc/group); \
1803 if [ -z "$OLD_UID" ]; then \
1804 echo "Remote user not found in /etc/passwd ($REMOTE_USER)."; \
1805 elif [ "$OLD_UID" = "$NEW_UID" -a "$OLD_GID" = "$NEW_GID" ]; then \
1806 echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \
1807 elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \
1808 echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \
1809 else \
1810 if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \
1811 FREE_GID=65532; \
1812 while grep -q ":[^:]*:${FREE_GID}:" /etc/group; do FREE_GID=$((FREE_GID - 1)); done; \
1813 echo "Reassigning group $EXISTING_GROUP from GID $NEW_GID to $FREE_GID."; \
1814 sed -i -e "s/\(${EXISTING_GROUP}:[^:]*:\)${NEW_GID}:/\1${FREE_GID}:/" /etc/group; \
1815 fi; \
1816 echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \
1817 sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \
1818 if [ "$OLD_GID" != "$NEW_GID" ]; then \
1819 sed -i -e "s/\([^:]*:[^:]*:\)${OLD_GID}:/\1${NEW_GID}:/" /etc/group; \
1820 fi; \
1821 chown -R $NEW_UID:$NEW_GID $HOME_FOLDER; \
1822 fi;
1823
1824ARG IMAGE_USER
1825USER $IMAGE_USER
1826
1827# Ensure that /etc/profile does not clobber the existing path
1828RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
1829"#.to_string();
1830 for feature in &self.features {
1831 let container_env_layer = feature.generate_dockerfile_env();
1832 dockerfile = format!("{dockerfile}\n{container_env_layer}");
1833 }
1834
1835 if let Some(env) = &self.dev_container().container_env {
1836 for (key, value) in env {
1837 dockerfile = format!("{dockerfile}ENV {key}={value}\n");
1838 }
1839 }
1840 dockerfile
1841 }
1842
1843 async fn build_feature_content_image(&self) -> Result<(), DevContainerError> {
1844 let Some(features_build_info) = &self.features_build_info else {
1845 log::error!("Features build info not available for building feature content image");
1846 return Err(DevContainerError::DevContainerParseFailed);
1847 };
1848 let features_content_dir = &features_build_info.features_content_dir;
1849
1850 let dockerfile_content = "FROM scratch\nCOPY . /tmp/build-features/\n";
1851 let dockerfile_path = features_content_dir.join("Dockerfile.feature-content");
1852
1853 self.fs
1854 .write(&dockerfile_path, dockerfile_content.as_bytes())
1855 .await
1856 .map_err(|e| {
1857 log::error!("Failed to write feature content Dockerfile: {e}");
1858 DevContainerError::FilesystemError
1859 })?;
1860
1861 let mut command = Command::new(self.docker_client.docker_cli());
1862 // This path runs only when BuildKit is unavailable, so force the classic
1863 // builder: the feature content image is consumed by a later multi-stage
1864 // `FROM`, which requires it to live in the daemon's image store.
1865 if self.docker_client.docker_cli() != "podman" {
1866 command.env("DOCKER_BUILDKIT", "0");
1867 }
1868 command.args([
1869 "build",
1870 "-t",
1871 "dev_container_feature_content_temp",
1872 "-f",
1873 &dockerfile_path.display().to_string(),
1874 &features_content_dir.display().to_string(),
1875 ]);
1876
1877 let output = self
1878 .command_runner
1879 .run_command(&mut command)
1880 .await
1881 .map_err(|e| {
1882 log::error!("Error building feature content image: {e}");
1883 DevContainerError::CommandFailed(self.docker_client.docker_cli())
1884 })?;
1885
1886 if !output.status.success() {
1887 let stderr = String::from_utf8_lossy(&output.stderr);
1888 log::error!("Feature content image build failed: {stderr}");
1889 return Err(DevContainerError::CommandFailed(
1890 self.docker_client.docker_cli(),
1891 ));
1892 }
1893
1894 Ok(())
1895 }
1896
1897 fn create_docker_build(&self) -> Result<Command, DevContainerError> {
1898 let dev_container = match &self.config {
1899 ConfigStatus::Deserialized(_) => {
1900 log::error!(
1901 "Dev container has not yet been parsed for variable expansion. Cannot yet proceed with docker build"
1902 );
1903 return Err(DevContainerError::DevContainerParseFailed);
1904 }
1905 ConfigStatus::VariableParsed(dev_container) => dev_container,
1906 };
1907
1908 let Some(features_build_info) = &self.features_build_info else {
1909 log::error!(
1910 "Cannot create docker build command; features build info has not been constructed"
1911 );
1912 return Err(DevContainerError::DevContainerParseFailed);
1913 };
1914 let mut command = Command::new(self.docker_client.docker_cli());
1915
1916 command.args(["buildx", "build"]);
1917
1918 // --load is short for --output=docker, loading the built image into the local docker images
1919 command.arg("--load");
1920
1921 // BuildKit build context: provides the features content directory as a named context
1922 // that the Dockerfile.extended can COPY from via `--from=dev_containers_feature_content_source`
1923 command.args([
1924 "--build-context",
1925 &format!(
1926 "dev_containers_feature_content_source={}",
1927 features_build_info.features_content_dir.display()
1928 ),
1929 ]);
1930
1931 // Build args matching the CLI reference implementation's `getFeaturesBuildOptions`
1932 if let Some(build_image) = &features_build_info.build_image {
1933 command.args([
1934 "--build-arg",
1935 &format!("_DEV_CONTAINERS_BASE_IMAGE={}", build_image),
1936 ]);
1937 } else {
1938 command.args([
1939 "--build-arg",
1940 "_DEV_CONTAINERS_BASE_IMAGE=dev_container_auto_added_stage_label",
1941 ]);
1942 }
1943
1944 command.args([
1945 "--build-arg",
1946 &format!(
1947 "_DEV_CONTAINERS_IMAGE_USER={}",
1948 self.root_image
1949 .as_ref()
1950 .and_then(|docker_image| docker_image.config.image_user.as_ref())
1951 .unwrap_or(&"root".to_string())
1952 ),
1953 ]);
1954
1955 command.args([
1956 "--build-arg",
1957 "_DEV_CONTAINERS_FEATURE_CONTENT_SOURCE=dev_container_feature_content_temp",
1958 ]);
1959
1960 if let Some(args) = dev_container.build.as_ref().and_then(|b| b.args.as_ref()) {
1961 for (key, value) in args {
1962 command.args(["--build-arg", &format!("{}={}", key, value)]);
1963 }
1964 }
1965
1966 if let Some(options) = dev_container
1967 .build
1968 .as_ref()
1969 .and_then(|b| b.options.as_ref())
1970 {
1971 for option in options {
1972 command.arg(option);
1973 }
1974 }
1975
1976 if let Some(cache_from_images) = dev_container
1977 .build
1978 .as_ref()
1979 .and_then(|b| b.cache_from.as_ref())
1980 {
1981 for cache_from_image in cache_from_images {
1982 command.args(["--cache-from", cache_from_image]);
1983 }
1984 }
1985
1986 command.args(["--target", "dev_containers_target_stage"]);
1987
1988 command.args([
1989 "-f",
1990 &features_build_info.dockerfile_path.display().to_string(),
1991 ]);
1992
1993 command.args(["-t", &features_build_info.image_tag]);
1994
1995 if let DevContainerBuildType::Dockerfile(build) = dev_container.build_type() {
1996 command.arg(self.calculate_context_dir(build).display().to_string());
1997 } else {
1998 // Use an empty folder as the build context to avoid pulling in unneeded files.
1999 // The actual feature content is supplied via the BuildKit build context above.
2000 command.arg(features_build_info.empty_context_dir.display().to_string());
2001 }
2002
2003 Ok(command)
2004 }
2005
2006 async fn run_docker_compose(
2007 &self,
2008 resources: DockerComposeResources,
2009 ) -> Result<DockerInspect, DevContainerError> {
2010 self.start_docker_compose_services(&resources, ComposeUpBehavior::Create)
2011 .await?;
2012
2013 if let Some(docker_ps) = self.check_for_existing_container().await? {
2014 log::debug!("Found newly created dev container");
2015 return self.docker_client.inspect(&docker_ps.id).await;
2016 }
2017
2018 log::error!("Could not find existing container after docker compose up");
2019
2020 Err(DevContainerError::DevContainerParseFailed)
2021 }
2022
2023 async fn start_docker_compose_services(
2024 &self,
2025 resources: &DockerComposeResources,
2026 behavior: ComposeUpBehavior,
2027 ) -> Result<(), DevContainerError> {
2028 let mut command = Command::new(self.docker_client.docker_cli());
2029 let project_name = self.project_name().await?;
2030 let compose_services = match self.dev_container().run_services.as_ref() {
2031 Some(run_services) => {
2032 let (main_service_name, _) = find_primary_service(&resources, self)?;
2033 compose_service_list(&main_service_name, Some(run_services))
2034 }
2035 None => None,
2036 };
2037 command.args(&["compose", "--project-name", &project_name]);
2038 for docker_compose_file in &resources.files {
2039 command.args(&["-f", &docker_compose_file.display().to_string()]);
2040 }
2041 command.args(&["up", "-d"]);
2042 if matches!(behavior, ComposeUpBehavior::Resume) {
2043 command.arg("--no-recreate");
2044 }
2045 if let Some(services) = compose_services.as_ref() {
2046 command.args(services);
2047 }
2048
2049 let output = self
2050 .command_runner
2051 .run_command(&mut command)
2052 .await
2053 .map_err(|e| {
2054 log::error!("Error running docker compose up: {e}");
2055 DevContainerError::CommandFailed(command.get_program().display().to_string())
2056 })?;
2057
2058 if !output.status.success() {
2059 let stderr = String::from_utf8_lossy(&output.stderr);
2060 log::error!("Non-success status from docker compose up: {}", stderr);
2061 return Err(DevContainerError::CommandFailed(
2062 command.get_program().display().to_string(),
2063 ));
2064 }
2065
2066 Ok(())
2067 }
2068
2069 async fn run_docker_image(
2070 &self,
2071 build_resources: DockerBuildResources,
2072 ) -> Result<DockerInspect, DevContainerError> {
2073 let mut docker_run_command = self.create_docker_run_command(build_resources)?;
2074
2075 let output = self
2076 .command_runner
2077 .run_command(&mut docker_run_command)
2078 .await
2079 .map_err(|e| {
2080 log::error!("Error running docker run: {e}");
2081 DevContainerError::CommandFailed(
2082 docker_run_command.get_program().display().to_string(),
2083 )
2084 })?;
2085
2086 if !output.status.success() {
2087 let std_err = String::from_utf8_lossy(&output.stderr);
2088 log::error!("Non-success status from docker run. StdErr: {std_err}");
2089 return Err(DevContainerError::CommandFailed(
2090 docker_run_command.get_program().display().to_string(),
2091 ));
2092 }
2093
2094 log::debug!("Checking for container that was started");
2095 let Some(docker_ps) = self.check_for_existing_container().await? else {
2096 log::error!("Could not locate container just created");
2097 return Err(DevContainerError::DevContainerParseFailed);
2098 };
2099 self.docker_client.inspect(&docker_ps.id).await
2100 }
2101
2102 fn local_workspace_folder(&self) -> String {
2103 self.local_project_directory.display().to_string()
2104 }
2105 fn local_workspace_base_name(&self) -> Result<String, DevContainerError> {
2106 self.local_project_directory
2107 .file_name()
2108 .map(|f| f.display().to_string())
2109 .ok_or(DevContainerError::DevContainerParseFailed)
2110 }
2111
2112 fn remote_workspace_folder(&self) -> Result<PathBuf, DevContainerError> {
2113 self.dev_container()
2114 .workspace_folder
2115 .as_ref()
2116 .map(|folder| PathBuf::from(folder))
2117 .or(Some(
2118 // We explicitly use "/" here, instead of PathBuf::join
2119 // because we want remote targets to use unix-style filepaths,
2120 // even on a Windows host
2121 PathBuf::from(format!(
2122 "{}/{}",
2123 DEFAULT_REMOTE_PROJECT_DIR,
2124 self.local_workspace_base_name()?
2125 )),
2126 ))
2127 .ok_or(DevContainerError::DevContainerParseFailed)
2128 }
2129 fn remote_workspace_base_name(&self) -> Result<String, DevContainerError> {
2130 self.remote_workspace_folder().and_then(|f| {
2131 f.file_name()
2132 .map(|file_name| file_name.display().to_string())
2133 .ok_or(DevContainerError::DevContainerParseFailed)
2134 })
2135 }
2136
2137 fn remote_workspace_mount(&self) -> Result<MountDefinition, DevContainerError> {
2138 if let Some(mount) = &self.dev_container().workspace_mount {
2139 return Ok(mount.clone());
2140 }
2141 let Some(project_directory_name) = self.local_project_directory.file_name() else {
2142 return Err(DevContainerError::DevContainerParseFailed);
2143 };
2144
2145 Ok(MountDefinition {
2146 source: Some(self.local_workspace_folder()),
2147 // We explicitly use "/" here, instead of PathBuf::join
2148 // because we want the remote target to use unix-style filepaths,
2149 // even on a Windows host
2150 target: format!(
2151 "{}/{}",
2152 PathBuf::from(DEFAULT_REMOTE_PROJECT_DIR).display(),
2153 project_directory_name.display()
2154 ),
2155 mount_type: None,
2156 })
2157 }
2158
2159 fn create_docker_run_command(
2160 &self,
2161 build_resources: DockerBuildResources,
2162 ) -> Result<Command, DevContainerError> {
2163 let remote_workspace_mount = self.remote_workspace_mount()?;
2164
2165 let docker_cli = self.docker_client.docker_cli();
2166 let mut command = Command::new(&docker_cli);
2167
2168 command.arg("run");
2169
2170 if build_resources.privileged {
2171 command.arg("--privileged");
2172 }
2173 if build_resources.init {
2174 command.arg("--init");
2175 }
2176
2177 let run_args = match &self.dev_container().run_args {
2178 Some(run_args) => run_args,
2179 None => &Vec::new(),
2180 };
2181
2182 for arg in run_args {
2183 command.arg(arg);
2184 }
2185
2186 let run_if_missing = {
2187 |arg_name: &str, arg: &str, command: &mut Command| {
2188 if !run_args
2189 .iter()
2190 .any(|arg| arg.strip_prefix(arg_name).is_some())
2191 {
2192 command.arg(arg);
2193 }
2194 }
2195 };
2196
2197 if &docker_cli == "podman" {
2198 run_if_missing(
2199 "--security-opt",
2200 "--security-opt=label=disable",
2201 &mut command,
2202 );
2203 run_if_missing("--userns", "--userns=keep-id", &mut command);
2204 }
2205
2206 run_if_missing("--sig-proxy", "--sig-proxy=false", &mut command);
2207 command.arg("-d");
2208 command.arg("--mount");
2209 command.arg(remote_workspace_mount.to_string());
2210
2211 for mount in &build_resources.additional_mounts {
2212 command.arg("--mount");
2213 command.arg(mount.to_string());
2214 }
2215
2216 for (key, val) in self.identifying_labels() {
2217 command.arg("-l");
2218 command.arg(format!("{}={}", key, val));
2219 }
2220
2221 {
2222 let mut metadata_entries: Vec<serde_json_lenient::Value> = Vec::new();
2223
2224 if let Some(image_metadata) = &build_resources.image.config.labels.metadata {
2225 for entry in image_metadata {
2226 if let Ok(val) = serde_json_lenient::to_value(entry) {
2227 metadata_entries.push(val);
2228 }
2229 }
2230 }
2231
2232 for feature in &self.features {
2233 let entry = feature.build_metadata_entry();
2234 if !entry.is_empty() {
2235 metadata_entries.push(serde_json_lenient::Value::Object(entry));
2236 }
2237 }
2238
2239 if let Ok(substituted_json) = self.parse_nonremote_vars_for_content(&self.raw_config) {
2240 let config_entry = build_devcontainer_metadata_entry(&substituted_json);
2241 if !config_entry.is_empty() {
2242 metadata_entries.push(serde_json_lenient::Value::Object(config_entry));
2243 }
2244 }
2245
2246 if !metadata_entries.is_empty() {
2247 let serialized = serde_json_lenient::to_string(&metadata_entries).map_err(|e| {
2248 log::error!("Problem serializing metadata: {e}");
2249 DevContainerError::ContainerNotValid(build_resources.image.id.clone())
2250 })?;
2251 command.arg("-l");
2252 command.arg(format!("devcontainer.metadata={serialized}"));
2253 }
2254 }
2255
2256 for cap in &build_resources.cap_add {
2257 command.arg("--cap-add");
2258 command.arg(cap);
2259 }
2260 for opt in &build_resources.security_opt {
2261 command.arg("--security-opt");
2262 command.arg(opt);
2263 }
2264
2265 if let Some(forward_ports) = &self.dev_container().forward_ports {
2266 for port in forward_ports {
2267 if let ForwardPort::Number(port_number) = port {
2268 command.arg("-p");
2269 command.arg(format!("{port_number}:{port_number}"));
2270 }
2271 }
2272 }
2273 for app_port in &self.dev_container().app_port {
2274 command.arg("-p");
2275 command.arg(app_port);
2276 }
2277
2278 for (key, value) in &build_resources.container_env {
2279 command.arg("-e");
2280 command.arg(format!("{key}={value}"));
2281 }
2282
2283 if let Some(entrypoint_script) = build_resources.entrypoint_script {
2284 command.arg("--entrypoint");
2285 command.arg("/bin/sh");
2286 command.arg(&build_resources.image_tag);
2287 command.arg("-c");
2288 command.arg(entrypoint_script);
2289 command.arg("-");
2290 } else {
2291 command.arg(&build_resources.image_tag);
2292 }
2293
2294 Ok(command)
2295 }
2296
2297 fn extension_ids(&self) -> Vec<String> {
2298 self.dev_container()
2299 .customizations
2300 .as_ref()
2301 .map(|c| c.zed.extensions.clone())
2302 .unwrap_or_default()
2303 }
2304
2305 async fn build_and_run(&mut self) -> Result<DevContainerUp, DevContainerError> {
2306 self.dev_container().validate_devcontainer_contents()?;
2307
2308 self.run_initialize_commands().await?;
2309
2310 self.download_feature_and_dockerfile_resources().await?;
2311
2312 let build_resources = self.build_resources().await?;
2313
2314 let devcontainer_up = self.run_dev_container(build_resources).await?;
2315
2316 self.run_remote_scripts(&devcontainer_up, true, true)
2317 .await?;
2318
2319 Ok(devcontainer_up)
2320 }
2321
2322 async fn run_remote_scripts(
2323 &self,
2324 devcontainer_up: &DevContainerUp,
2325 new_container: bool,
2326 container_started: bool,
2327 ) -> Result<(), DevContainerError> {
2328 let ConfigStatus::VariableParsed(config) = &self.config else {
2329 log::error!("Config not yet parsed, cannot proceed with remote scripts");
2330 return Err(DevContainerError::DevContainerScriptsFailed);
2331 };
2332 let remote_folder = self.remote_workspace_folder()?.display().to_string();
2333
2334 if new_container {
2335 if let Some(on_create_command) = &config.on_create_command {
2336 for (command_name, command) in on_create_command.script_commands() {
2337 log::debug!("Running on create command {command_name}");
2338 self.docker_client
2339 .run_docker_exec(
2340 &devcontainer_up.container_id,
2341 &remote_folder,
2342 &devcontainer_up.remote_user,
2343 &devcontainer_up.remote_env,
2344 command,
2345 )
2346 .await?;
2347 }
2348 }
2349 if let Some(update_content_command) = &config.update_content_command {
2350 for (command_name, command) in update_content_command.script_commands() {
2351 log::debug!("Running update content command {command_name}");
2352 self.docker_client
2353 .run_docker_exec(
2354 &devcontainer_up.container_id,
2355 &remote_folder,
2356 &devcontainer_up.remote_user,
2357 &devcontainer_up.remote_env,
2358 command,
2359 )
2360 .await?;
2361 }
2362 }
2363
2364 if let Some(post_create_command) = &config.post_create_command {
2365 for (command_name, command) in post_create_command.script_commands() {
2366 log::debug!("Running post create command {command_name}");
2367 self.docker_client
2368 .run_docker_exec(
2369 &devcontainer_up.container_id,
2370 &remote_folder,
2371 &devcontainer_up.remote_user,
2372 &devcontainer_up.remote_env,
2373 command,
2374 )
2375 .await?;
2376 }
2377 }
2378 }
2379 if let Some(post_start_command) = &config.post_start_command {
2380 let script_commands = post_start_command.script_commands();
2381 if let Some(started_at) = &devcontainer_up.started_at {
2382 if !script_commands.is_empty() {
2383 for command_name in script_commands.keys() {
2384 log::debug!("Running post start command {command_name}");
2385 }
2386 let script = post_start_marker_script(started_at, script_commands);
2387 self.docker_client
2388 .run_docker_exec(
2389 &devcontainer_up.container_id,
2390 &remote_folder,
2391 &devcontainer_up.remote_user,
2392 &devcontainer_up.remote_env,
2393 Command::new(script),
2394 )
2395 .await?;
2396 }
2397 } else if container_started {
2398 for (command_name, command) in script_commands {
2399 log::debug!("Running post start command {command_name}");
2400 self.docker_client
2401 .run_docker_exec(
2402 &devcontainer_up.container_id,
2403 &remote_folder,
2404 &devcontainer_up.remote_user,
2405 &devcontainer_up.remote_env,
2406 command,
2407 )
2408 .await?;
2409 }
2410 }
2411 }
2412 if let Some(post_attach_command) = &config.post_attach_command {
2413 for (command_name, command) in post_attach_command.script_commands() {
2414 log::debug!("Running post attach command {command_name}");
2415 self.docker_client
2416 .run_docker_exec(
2417 &devcontainer_up.container_id,
2418 &remote_folder,
2419 &devcontainer_up.remote_user,
2420 &devcontainer_up.remote_env,
2421 command,
2422 )
2423 .await?;
2424 }
2425 }
2426
2427 Ok(())
2428 }
2429
2430 async fn run_initialize_commands(&self) -> Result<(), DevContainerError> {
2431 let ConfigStatus::VariableParsed(config) = &self.config else {
2432 log::error!("Config not yet parsed, cannot proceed with initializeCommand");
2433 return Err(DevContainerError::DevContainerParseFailed);
2434 };
2435
2436 if let Some(initialize_command) = &config.initialize_command {
2437 log::debug!("Running initialize command");
2438 initialize_command
2439 .run(&self.command_runner, &self.local_project_directory)
2440 .await
2441 } else {
2442 log::warn!("No initialize command found");
2443 Ok(())
2444 }
2445 }
2446
2447 async fn check_for_existing_devcontainer(
2448 &self,
2449 ) -> Result<Option<DevContainerUp>, DevContainerError> {
2450 if let Some(docker_ps) = self.check_for_existing_container().await? {
2451 log::debug!("Dev container already found. Proceeding with it");
2452
2453 let mut docker_inspect = self.docker_client.inspect(&docker_ps.id).await?;
2454
2455 let container_started = !docker_inspect.is_running();
2456 if container_started {
2457 log::debug!("Container not running. Will attempt to start, and then proceed");
2458 match self.dev_container().build_type() {
2459 DevContainerBuildType::DockerCompose => {
2460 let resources = self.docker_compose_manifest().await?;
2461 self.start_docker_compose_services(&resources, ComposeUpBehavior::Resume)
2462 .await?
2463 }
2464 _ => self.docker_client.start_container(&docker_ps.id).await?,
2465 }
2466 docker_inspect = self.docker_client.inspect(&docker_ps.id).await?;
2467 }
2468
2469 let remote_user = get_remote_user_from_config(&docker_inspect, self)?;
2470
2471 let remote_folder = self.remote_workspace_folder()?;
2472
2473 let remote_env = self.runtime_remote_env(&docker_inspect.config.env_as_map()?)?;
2474
2475 let dev_container_up = DevContainerUp {
2476 started_at: docker_inspect
2477 .state
2478 .as_ref()
2479 .and_then(|state| state.started_at.clone()),
2480 container_id: docker_ps.id,
2481 remote_user: remote_user,
2482 remote_workspace_folder: remote_folder.display().to_string(),
2483 extension_ids: self.extension_ids(),
2484 remote_env,
2485 };
2486
2487 self.run_remote_scripts(&dev_container_up, false, container_started)
2488 .await?;
2489
2490 Ok(Some(dev_container_up))
2491 } else {
2492 log::debug!("Existing container not found.");
2493
2494 Ok(None)
2495 }
2496 }
2497
2498 async fn check_for_existing_container(&self) -> Result<Option<DockerPs>, DevContainerError> {
2499 self.docker_client
2500 .find_process_by_filters(
2501 self.identifying_labels()
2502 .iter()
2503 .map(|(k, v)| format!("label={k}={v}"))
2504 .collect(),
2505 )
2506 .await
2507 }
2508
2509 /// Matches `@devcontainers/cli`'s `getProjectName` in
2510 /// `src/spec-node/dockerCompose.ts`. See `derive_project_name` for the
2511 /// full precedence. Using the devcontainer.json `name` field here
2512 /// diverges from the reference CLI and creates duplicate compose
2513 /// projects when the same folder is opened by both tools — see #54255.
2514 ///
2515 /// Async because the derivation reads both the workspace `.env` file
2516 /// and the merged compose config — neither of which is available
2517 /// synchronously.
2518 async fn project_name(&self) -> Result<String, DevContainerError> {
2519 let workspace_fallback = self
2520 .local_workspace_base_name()
2521 .unwrap_or_else(|_| self.local_workspace_folder());
2522 let compose_resources = self.docker_compose_manifest().await.ok();
2523 let first_compose_file = compose_resources
2524 .as_ref()
2525 .and_then(|r| r.files.first())
2526 .map(PathBuf::as_path);
2527 let compose_config_name = compose_resources
2528 .as_ref()
2529 .and_then(|r| r.config.name.as_deref());
2530 let mut compose_name_explicitly_declared = false;
2531 if let Some(resources) = &compose_resources {
2532 for file in &resources.files {
2533 // Mirrors the CLI's fragment re-parse (dockerCompose.ts 663-673):
2534 // the whole readFile+yaml.load pair is wrapped in a single
2535 // try/catch that swallows every failure. The comment there
2536 // calls out `!reset` custom tags; the behavior is "on any
2537 // failure, treat the fragment as not-declared and keep
2538 // scanning." Propagating an I/O error here would diverge
2539 // from that policy and fail the whole devcontainer flow for
2540 // a fragment the CLI would have silently skipped.
2541 let contents = match self.fs.load(file).await {
2542 Ok(contents) => contents,
2543 Err(err) => {
2544 log::warn!(
2545 "Ignoring unreadable compose fragment `{}` while deriving project name: {err:?}",
2546 file.display()
2547 );
2548 continue;
2549 }
2550 };
2551 if compose_fragment_declares_name(&contents) {
2552 compose_name_explicitly_declared = true;
2553 break;
2554 }
2555 }
2556 }
2557 let dotenv_path = self.local_project_directory.join(".env");
2558 let dotenv_contents = match self.fs.load(&dotenv_path).await {
2559 Ok(contents) => Some(contents),
2560 Err(err) if is_missing_file_error(&err) => None,
2561 Err(err) => {
2562 // Mirrors the CLI: `getProjectName` only swallows `ENOENT`/
2563 // `EISDIR` on the `.env` read. Any other error (permission
2564 // denied, I/O failure, …) must surface so we don't silently
2565 // fall back to a non-canonical project name and create a
2566 // second compose project for the same repo.
2567 log::error!(
2568 "Failed to read workspace .env `{}` while deriving project name: {err:?}",
2569 dotenv_path.display()
2570 );
2571 return Err(DevContainerError::FilesystemError);
2572 }
2573 };
2574 Ok(derive_project_name(
2575 &self.local_environment,
2576 dotenv_contents.as_deref(),
2577 compose_config_name,
2578 compose_name_explicitly_declared,
2579 first_compose_file,
2580 &self.local_project_directory,
2581 &workspace_fallback,
2582 ))
2583 }
2584
2585 async fn expanded_dockerfile_content(&self) -> Result<String, DevContainerError> {
2586 let Some(dockerfile_path) = self.dockerfile_location().await else {
2587 log::error!("Tried to expand dockerfile for an image-type config");
2588 return Err(DevContainerError::DevContainerParseFailed);
2589 };
2590
2591 // For docker-compose configs the build args live on the primary
2592 // compose service rather than on dev_container.build.
2593 let devcontainer_args = match self.dev_container().build_type() {
2594 DevContainerBuildType::DockerCompose => {
2595 let compose = self.docker_compose_manifest().await?;
2596 find_primary_service(&compose, self)?
2597 .1
2598 .build
2599 .and_then(|b| b.args)
2600 .unwrap_or_default()
2601 }
2602 _ => self
2603 .dev_container()
2604 .build
2605 .as_ref()
2606 .and_then(|b| b.args.clone())
2607 .unwrap_or_default(),
2608 };
2609 let contents = self.fs.load(&dockerfile_path).await.map_err(|e| {
2610 log::error!("Failed to load Dockerfile: {e}");
2611 DevContainerError::FilesystemError
2612 })?;
2613 let mut parsed_lines: Vec<String> = Vec::new();
2614 let mut inline_args: Vec<(String, String)> = Vec::new();
2615 let key_regex = Regex::new(r"(?:^|\s)(\w+)=").expect("valid regex");
2616
2617 for line in contents.lines() {
2618 let mut parsed_line = line.to_string();
2619 // Replace from devcontainer args first, since they take precedence
2620 for (key, value) in &devcontainer_args {
2621 parsed_line = expand_dockerfile_var(parsed_line, key, value);
2622 }
2623 for (key, value) in &inline_args {
2624 parsed_line = expand_dockerfile_var(parsed_line, key, value);
2625 }
2626 if let Some(arg_directives) = parsed_line.strip_prefix("ARG ") {
2627 let trimmed = arg_directives.trim();
2628 let key_matches: Vec<_> = key_regex.captures_iter(trimmed).collect();
2629 for (i, captures) in key_matches.iter().enumerate() {
2630 let key = captures[1].to_string();
2631 // Insert the devcontainer overrides here if needed
2632 let value_start = captures.get(0).expect("full match").end();
2633 let value_end = if i + 1 < key_matches.len() {
2634 key_matches[i + 1].get(0).expect("full match").start()
2635 } else {
2636 trimmed.len()
2637 };
2638 let raw_value = trimmed[value_start..value_end].trim();
2639 let value = if raw_value.starts_with('"')
2640 && raw_value.ends_with('"')
2641 && raw_value.len() > 1
2642 {
2643 &raw_value[1..raw_value.len() - 1]
2644 } else {
2645 raw_value
2646 };
2647 inline_args.push((key, value.to_string()));
2648 }
2649 }
2650 parsed_lines.push(parsed_line);
2651 }
2652
2653 Ok(parsed_lines.join("\n"))
2654 }
2655
2656 fn calculate_context_dir(&self, build: ContainerBuild) -> PathBuf {
2657 let Some(context) = build.context else {
2658 return self.config_directory.clone();
2659 };
2660 let context_path = PathBuf::from(context);
2661
2662 if context_path.is_absolute() {
2663 context_path
2664 } else {
2665 self.config_directory.join(context_path)
2666 }
2667 }
2668}
2669
2670/// Holds all the information needed to construct a `docker buildx build` command
2671/// that extends a base image with dev container features.
2672///
2673/// This mirrors the `ImageBuildOptions` interface in the CLI reference implementation
2674/// (cli/src/spec-node/containerFeatures.ts).
2675#[derive(Debug, Eq, PartialEq)]
2676pub(crate) struct FeaturesBuildInfo {
2677 /// Path to the generated Dockerfile.extended
2678 pub dockerfile_path: PathBuf,
2679 /// Path to the features content directory (used as a BuildKit build context)
2680 pub features_content_dir: PathBuf,
2681 /// Path to an empty directory used as the Docker build context
2682 pub empty_context_dir: PathBuf,
2683 /// The base image name (e.g. "mcr.microsoft.com/devcontainers/rust:2-1-bookworm")
2684 pub build_image: Option<String>,
2685 /// The tag to apply to the built image (e.g. "vsc-myproject-features")
2686 pub image_tag: String,
2687}
2688
2689pub(crate) async fn read_devcontainer_configuration(
2690 config: DevContainerConfig,
2691 context: &DevContainerContext,
2692 environment: HashMap<String, String>,
2693) -> Result<DevContainer, DevContainerError> {
2694 let docker = if context.use_podman {
2695 Docker::new("podman", context.use_buildkit).await
2696 } else {
2697 Docker::new("docker", context.use_buildkit).await
2698 };
2699 let mut dev_container = DevContainerManifest::new(
2700 context,
2701 environment,
2702 Arc::new(docker),
2703 Arc::new(DefaultCommandRunner::new()),
2704 config,
2705 &context.project_directory.as_ref(),
2706 )
2707 .await?;
2708 dev_container.parse_nonremote_vars()?;
2709 Ok(dev_container.dev_container().clone())
2710}
2711
2712pub(crate) async fn spawn_dev_container(
2713 context: &DevContainerContext,
2714 environment: HashMap<String, String>,
2715 config: DevContainerConfig,
2716 local_project_path: &Path,
2717) -> Result<DevContainerUp, DevContainerError> {
2718 let docker = if context.use_podman {
2719 Docker::new("podman", context.use_buildkit).await
2720 } else {
2721 Docker::new("docker", context.use_buildkit).await
2722 };
2723 let mut devcontainer_manifest = DevContainerManifest::new(
2724 context,
2725 environment,
2726 Arc::new(docker),
2727 Arc::new(DefaultCommandRunner::new()),
2728 config,
2729 local_project_path,
2730 )
2731 .await?;
2732
2733 devcontainer_manifest.parse_nonremote_vars()?;
2734
2735 log::debug!("Checking for existing container");
2736 if let Some(devcontainer) = devcontainer_manifest
2737 .check_for_existing_devcontainer()
2738 .await?
2739 {
2740 Ok(devcontainer)
2741 } else {
2742 log::debug!("Existing container not found. Building");
2743
2744 devcontainer_manifest.build_and_run().await
2745 }
2746}
2747
2748#[derive(Debug)]
2749struct DockerBuildResources {
2750 image: DockerInspect,
2751 image_tag: String,
2752 additional_mounts: Vec<MountDefinition>,
2753 container_env: HashMap<String, String>,
2754 privileged: bool,
2755 init: bool,
2756 cap_add: Vec<String>,
2757 security_opt: Vec<String>,
2758 entrypoint_script: Option<String>,
2759}
2760
2761#[derive(Debug)]
2762enum DevContainerBuildResources {
2763 DockerCompose(DockerComposeResources),
2764 Docker(DockerBuildResources),
2765}
2766
2767/// Replaces occurrences of `${KEY}` and `$KEY` in `line` with `value`.
2768/// Bare `$KEY` is only replaced when the character immediately after the key
2769/// is not a word character (`[A-Za-z0-9_]`), so `$RUBY_VERSION2` is not
2770/// partially consumed when expanding `$RUBY_VERSION`.
2771fn expand_dockerfile_var(mut line: String, key: &str, value: &str) -> String {
2772 line = line.replace(&format!("${{{key}}}"), value);
2773 let pattern = format!("${key}");
2774 let mut result = String::with_capacity(line.len());
2775 let mut remaining = line.as_str();
2776 while let Some(pos) = remaining.find(pattern.as_str()) {
2777 result.push_str(&remaining[..pos]);
2778 let after = &remaining[pos + pattern.len()..];
2779 if after.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
2780 result.push('$');
2781 remaining = &remaining[pos + 1..];
2782 } else {
2783 result.push_str(value);
2784 remaining = after;
2785 }
2786 }
2787 result.push_str(remaining);
2788 result
2789}
2790
2791fn find_primary_service(
2792 docker_compose: &DockerComposeResources,
2793 devcontainer: &DevContainerManifest,
2794) -> Result<(String, DockerComposeService), DevContainerError> {
2795 let Some(service_name) = &devcontainer.dev_container().service else {
2796 return Err(DevContainerError::DevContainerParseFailed);
2797 };
2798
2799 match docker_compose.config.services.get(service_name) {
2800 Some(service) => Ok((service_name.clone(), service.clone())),
2801 None => Err(DevContainerError::DevContainerParseFailed),
2802 }
2803}
2804
2805/// The compose service list for `build` and `up`. Per the devcontainer spec
2806/// `runServices` are *additional*, so the primary `service` is always prepended.
2807/// `None` (no `runServices`) lets compose operate every service.
2808fn compose_service_list(
2809 main_service_name: &str,
2810 run_services: Option<&Vec<String>>,
2811) -> Option<Vec<String>> {
2812 let run_services = run_services?;
2813 let mut services = Vec::with_capacity(run_services.len() + 1);
2814 services.push(main_service_name.to_string());
2815 for service in run_services {
2816 if service != main_service_name {
2817 services.push(service.clone());
2818 }
2819 }
2820 Some(services)
2821}
2822
2823/// Resolves a compose service's dockerfile path according to the Docker Compose spec:
2824/// `dockerfile` is relative to the build `context`, and `context` is relative to
2825/// the compose file's directory.
2826fn resolve_compose_dockerfile(
2827 compose_file: &Path,
2828 context: Option<&str>,
2829 dockerfile: &str,
2830) -> Option<PathBuf> {
2831 let dockerfile = PathBuf::from(dockerfile);
2832 if dockerfile.is_absolute() {
2833 return Some(dockerfile);
2834 }
2835 let compose_dir = compose_file.parent()?;
2836 let context_dir = match context {
2837 Some(ctx) => {
2838 let ctx = PathBuf::from(ctx);
2839 if ctx.is_absolute() {
2840 ctx
2841 } else {
2842 normalize_path(&compose_dir.join(ctx))
2843 }
2844 }
2845 None => compose_dir.to_path_buf(),
2846 };
2847 Some(context_dir.join(dockerfile))
2848}
2849
2850/// Destination folder inside the container where feature content is staged during build.
2851/// Mirrors the CLI's `FEATURES_CONTAINER_TEMP_DEST_FOLDER`.
2852const FEATURES_CONTAINER_TEMP_DEST_FOLDER: &str = "/tmp/dev-container-features";
2853
2854/// Escapes regex special characters in a string.
2855fn escape_regex_chars(input: &str) -> String {
2856 let mut result = String::with_capacity(input.len() * 2);
2857 for c in input.chars() {
2858 if ".*+?^${}()|[]\\".contains(c) {
2859 result.push('\\');
2860 }
2861 result.push(c);
2862 }
2863 result
2864}
2865
2866/// Sanitize a string for use as a Docker Compose project name, matching
2867/// `@devcontainers/cli`'s `toProjectName` (modern Compose branch): lowercase
2868/// the input and strip any character outside `[-_a-z0-9]`.
2869fn sanitize_compose_project_name(input: &str) -> String {
2870 input
2871 .chars()
2872 .flat_map(|c| c.to_lowercase())
2873 .filter(|c| c.is_ascii_digit() || c.is_ascii_lowercase() || *c == '-' || *c == '_')
2874 .collect()
2875}
2876
2877/// Derive the Docker Compose project name, mirroring `getProjectName` in
2878/// `@devcontainers/cli`'s `src/spec-node/dockerCompose.ts`. Precedence:
2879///
2880/// 1. `COMPOSE_PROJECT_NAME` from the local environment.
2881/// 2. `COMPOSE_PROJECT_NAME` from the workspace `.env` file.
2882/// 3. The top-level `name:` field of the merged compose config, but only
2883/// when at least one compose fragment explicitly declared `name:`.
2884/// Compose injects a default `name: devcontainer` into its merged
2885/// output whenever no fragment declared one — that default must NOT be
2886/// treated as a user-provided name, so rule 4 applies instead.
2887/// 4. Basename of the first compose file's directory, appending
2888/// `_devcontainer` only when that directory is
2889/// `<workspace_root>/.devcontainer`.
2890///
2891/// The caller is responsible for computing `compose_name_explicitly_declared`
2892/// by scanning the original compose fragments for a top-level `name:` key
2893/// (the reference CLI does the same). This keeps the helper a pure function
2894/// of its inputs.
2895///
2896/// All branches pass through `sanitize_compose_project_name` — the CLI's
2897/// final normalization step.
2898fn derive_project_name(
2899 local_environment: &HashMap<String, String>,
2900 workspace_dotenv_contents: Option<&str>,
2901 compose_config_name: Option<&str>,
2902 compose_name_explicitly_declared: bool,
2903 first_compose_file: Option<&Path>,
2904 workspace_root: &Path,
2905 workspace_fallback: &str,
2906) -> String {
2907 if let Some(env_name) = local_environment.get("COMPOSE_PROJECT_NAME")
2908 && !env_name.is_empty()
2909 {
2910 return sanitize_compose_project_name(env_name);
2911 }
2912 if let Some(contents) = workspace_dotenv_contents
2913 && let Some(dotenv_name) = parse_dotenv_compose_project_name(contents)
2914 && !dotenv_name.is_empty()
2915 {
2916 return sanitize_compose_project_name(&dotenv_name);
2917 }
2918 if let Some(name) = compose_config_name
2919 && !name.is_empty()
2920 && compose_name_explicitly_declared
2921 {
2922 return sanitize_compose_project_name(name);
2923 }
2924 let compose_dir = first_compose_file.and_then(Path::parent);
2925 let canonical_devcontainer_dir = normalize_path(&workspace_root.join(".devcontainer"));
2926 let raw = match compose_dir {
2927 Some(dir) if dir == canonical_devcontainer_dir => {
2928 // Matches the CLI's `configDir/.devcontainer` branch: use the
2929 // *workspace root's* basename with the `_devcontainer` suffix,
2930 // NOT the `.devcontainer` dir's basename.
2931 format!("{workspace_fallback}_devcontainer")
2932 }
2933 Some(dir) => dir
2934 .file_name()
2935 .map(|f| f.to_string_lossy().into_owned())
2936 .unwrap_or_else(|| workspace_fallback.to_string()),
2937 None => format!("{workspace_fallback}_devcontainer"),
2938 };
2939 sanitize_compose_project_name(&raw)
2940}
2941
2942/// Classify an anyhow error from `Fs::load` as "file does not exist" vs a
2943/// real I/O failure. Used on the `.env` read in `project_name()`, where the
2944/// CLI's `getProjectName` catches only `ENOENT`/`EISDIR` and rethrows
2945/// everything else; any other error must propagate so callers can surface
2946/// the problem instead of silently falling back to a non-canonical project
2947/// name. (The fragment-rescan loop uses a different, broader swallow —
2948/// the CLI wraps its fragment read+parse in one try/catch that ignores
2949/// every failure.)
2950fn is_missing_file_error(err: &anyhow::Error) -> bool {
2951 err.downcast_ref::<std::io::Error>().is_some_and(|e| {
2952 matches!(
2953 e.kind(),
2954 std::io::ErrorKind::NotFound | std::io::ErrorKind::IsADirectory
2955 )
2956 })
2957}
2958
2959/// Extract `COMPOSE_PROJECT_NAME` from a `.env` file's contents. Matches
2960/// the subset of dotenv syntax that `@devcontainers/cli`'s regex parser
2961/// recognizes: a bare `COMPOSE_PROJECT_NAME=value` line (no `export` prefix,
2962/// no quoting, no line continuation). Comment lines are skipped.
2963fn parse_dotenv_compose_project_name(contents: &str) -> Option<String> {
2964 for line in contents.lines() {
2965 let trimmed = line.trim_start();
2966 if trimmed.starts_with('#') {
2967 continue;
2968 }
2969 if let Some(value) = trimmed.strip_prefix("COMPOSE_PROJECT_NAME=") {
2970 return Some(value.trim().to_string());
2971 }
2972 }
2973 None
2974}
2975
2976/// Detect whether a compose-file fragment declares a top-level `name:` key.
2977/// Matches the reference CLI's approach: parse the fragment as YAML and check
2978/// for a `name` key on the root mapping. This handles all valid styles —
2979/// block mappings, quoted keys (`"name":`), flow-style root mappings, anchors,
2980/// etc. On parse failure we fall through (return `false`), matching the CLI's
2981/// own behavior when fragment parsing errors.
2982fn compose_fragment_declares_name(contents: &str) -> bool {
2983 let Ok(docs) = yaml_rust2::YamlLoader::load_from_str(contents) else {
2984 return false;
2985 };
2986 let Some(yaml_rust2::Yaml::Hash(h)) = docs.into_iter().next() else {
2987 return false;
2988 };
2989 h.contains_key(&yaml_rust2::Yaml::String("name".to_string()))
2990}
2991
2992/// Extracts the short feature ID from a full feature reference string.
2993///
2994/// Examples:
2995/// - `ghcr.io/devcontainers/features/aws-cli:1` → `aws-cli`
2996/// - `ghcr.io/user/repo/go` → `go`
2997/// - `ghcr.io/devcontainers/features/rust@sha256:abc` → `rust`
2998/// - `./myFeature` → `myFeature`
2999fn extract_feature_id(feature_ref: &str) -> &str {
3000 let without_version = if let Some(at_idx) = feature_ref.rfind('@') {
3001 &feature_ref[..at_idx]
3002 } else {
3003 let last_slash = feature_ref.rfind('/');
3004 let last_colon = feature_ref.rfind(':');
3005 match (last_slash, last_colon) {
3006 (Some(slash), Some(colon)) if colon > slash => &feature_ref[..colon],
3007 _ => feature_ref,
3008 }
3009 };
3010 match without_version.rfind('/') {
3011 Some(idx) => &without_version[idx + 1..],
3012 None => without_version,
3013 }
3014}
3015
3016fn is_local_feature_ref(feature_ref: &str) -> bool {
3017 feature_ref.starts_with("./") || feature_ref.starts_with("../")
3018}
3019
3020/// Generates a shell command that looks up a user's passwd entry.
3021///
3022/// Mirrors the CLI's `getEntPasswdShellCommand` in `commonUtils.ts`.
3023/// Tries `getent passwd` first, then falls back to grepping `/etc/passwd`.
3024fn get_ent_passwd_shell_command(user: &str) -> String {
3025 let escaped_for_shell = user.replace('\\', "\\\\").replace('\'', "\\'");
3026 let escaped_for_regex = escape_regex_chars(user).replace('\'', "\\'");
3027 format!(
3028 " (command -v getent >/dev/null 2>&1 && getent passwd '{shell}' || grep -E '^{re}|^[^:]*:[^:]*:{re}:' /etc/passwd || true)",
3029 shell = escaped_for_shell,
3030 re = escaped_for_regex,
3031 )
3032}
3033
3034/// Determines feature installation order, respecting `overrideFeatureInstallOrder`.
3035///
3036/// Features listed in the override come first (in the specified order), followed
3037/// by any remaining features sorted lexicographically by their full reference ID.
3038fn resolve_feature_order<'a>(
3039 features: &'a HashMap<String, FeatureOptions>,
3040 override_order: &Option<Vec<String>>,
3041) -> Vec<(&'a String, &'a FeatureOptions)> {
3042 if let Some(order) = override_order {
3043 let mut ordered: Vec<(&'a String, &'a FeatureOptions)> = Vec::new();
3044 for ordered_id in order {
3045 if let Some((key, options)) = features.get_key_value(ordered_id) {
3046 ordered.push((key, options));
3047 }
3048 }
3049 let mut remaining: Vec<_> = features
3050 .iter()
3051 .filter(|(id, _)| !order.iter().any(|o| o == *id))
3052 .collect();
3053 remaining.sort_by_key(|(id, _)| id.as_str());
3054 ordered.extend(remaining);
3055 ordered
3056 } else {
3057 let mut entries: Vec<_> = features.iter().collect();
3058 entries.sort_by_key(|(id, _)| id.as_str());
3059 entries
3060 }
3061}
3062
3063/// Generates the `devcontainer-features-install.sh` wrapper script for one feature.
3064///
3065/// Mirrors the CLI's `getFeatureInstallWrapperScript` in
3066/// `containerFeaturesConfiguration.ts`.
3067fn generate_install_wrapper(
3068 feature_ref: &str,
3069 feature_id: &str,
3070 env_variables: &str,
3071) -> Result<String, DevContainerError> {
3072 let escaped_id = shlex::try_quote(feature_ref).map_err(|e| {
3073 log::error!("Error escaping feature ref {feature_ref}: {e}");
3074 DevContainerError::DevContainerParseFailed
3075 })?;
3076 let escaped_name = shlex::try_quote(feature_id).map_err(|e| {
3077 log::error!("Error escaping feature {feature_id}: {e}");
3078 DevContainerError::DevContainerParseFailed
3079 })?;
3080 let options_indented: String = env_variables
3081 .lines()
3082 .filter(|l| !l.is_empty())
3083 .map(|l| format!(" {}", l))
3084 .collect::<Vec<_>>()
3085 .join("\n");
3086 let escaped_options = shlex::try_quote(&options_indented).map_err(|e| {
3087 log::error!("Error escaping options {options_indented}: {e}");
3088 DevContainerError::DevContainerParseFailed
3089 })?;
3090
3091 let script = format!(
3092 r#"#!/bin/sh
3093set -e
3094
3095on_exit () {{
3096 [ $? -eq 0 ] && exit
3097 echo 'ERROR: Feature "{escaped_name}" ({escaped_id}) failed to install!'
3098}}
3099
3100trap on_exit EXIT
3101
3102echo ===========================================================================
3103echo 'Feature : {escaped_name}'
3104echo 'Id : {escaped_id}'
3105echo 'Options :'
3106echo {escaped_options}
3107echo ===========================================================================
3108
3109set -a
3110. ../devcontainer-features.builtin.env
3111. ./devcontainer-features.env
3112set +a
3113
3114chmod +x ./install.sh
3115./install.sh
3116"#
3117 );
3118
3119 Ok(script)
3120}
3121
3122struct ParsedFromLine<'a> {
3123 image: &'a str,
3124 alias: Option<&'a str>,
3125}
3126
3127/// Parses a `FROM` instruction into its image and optional stage alias,
3128/// skipping flags like `--platform=...`. Returns `None` for non-`FROM` lines.
3129fn parse_from_line(line: &str) -> Option<ParsedFromLine<'_>> {
3130 let mut tokens = line.split_whitespace();
3131 if !tokens.next()?.eq_ignore_ascii_case("FROM") {
3132 return None;
3133 }
3134 let image = tokens.find(|token| !token.starts_with("--"))?;
3135 let alias = match (tokens.next(), tokens.next()) {
3136 (Some(keyword), Some(alias)) if keyword.eq_ignore_ascii_case("as") => Some(alias),
3137 _ => None,
3138 };
3139 Some(ParsedFromLine { image, alias })
3140}
3141
3142fn dockerfile_inject_alias(
3143 dockerfile_content: &str,
3144 alias: &str,
3145 build_target: Option<String>,
3146) -> String {
3147 let from_lines: Vec<(usize, ParsedFromLine)> = dockerfile_content
3148 .lines()
3149 .enumerate()
3150 .filter_map(|(index, line)| parse_from_line(line).map(|parsed| (index, parsed)))
3151 .collect();
3152
3153 let target_entry = match &build_target {
3154 Some(target) => from_lines.iter().rfind(|(_, parsed)| {
3155 parsed
3156 .alias
3157 .is_some_and(|alias| alias.eq_ignore_ascii_case(target))
3158 }),
3159 None => from_lines.last(),
3160 };
3161
3162 let Some((line_idx, parsed)) = target_entry else {
3163 match &build_target {
3164 Some(target) => log::warn!(
3165 "Build target stage {target:?} not found in Dockerfile; leaving it unmodified"
3166 ),
3167 None => log::warn!("No FROM instruction found in Dockerfile; leaving it unmodified"),
3168 }
3169 return dockerfile_content.to_string();
3170 };
3171
3172 if let Some(existing_alias) = parsed.alias {
3173 format!("{dockerfile_content}\nFROM {existing_alias} AS {alias}")
3174 } else {
3175 let lines: Vec<&str> = dockerfile_content.lines().collect();
3176 // Appending ` AS {alias}` to a line ending in a `\` continuation would
3177 // corrupt the instruction, so leave the Dockerfile unmodified.
3178 if lines
3179 .get(*line_idx)
3180 .is_some_and(|line| line.trim_end().ends_with('\\'))
3181 {
3182 log::warn!(
3183 "FROM instruction spans multiple lines via `\\` continuation; cannot inject stage alias, leaving Dockerfile unmodified"
3184 );
3185 return dockerfile_content.to_string();
3186 }
3187 let mut result = String::new();
3188 for (i, line) in lines.iter().enumerate() {
3189 if i > 0 {
3190 result.push('\n');
3191 }
3192 if i == *line_idx {
3193 result.push_str(&format!("{line} AS {alias}"));
3194 } else {
3195 result.push_str(line);
3196 }
3197 }
3198 if dockerfile_content.ends_with('\n') {
3199 result.push('\n');
3200 }
3201 result
3202 }
3203}
3204
3205fn image_from_dockerfile(dockerfile_contents: String, target: &Option<String>) -> Option<String> {
3206 let stages: Vec<ParsedFromLine> = dockerfile_contents
3207 .lines()
3208 .filter_map(parse_from_line)
3209 .collect();
3210
3211 let start_index = match target {
3212 Some(target) => stages.iter().rposition(|stage| {
3213 stage
3214 .alias
3215 .is_some_and(|alias| alias.eq_ignore_ascii_case(target))
3216 })?,
3217 None => stages.len().checked_sub(1)?,
3218 };
3219
3220 // Follow alias chains (`FROM base AS development`) to a concrete image.
3221 // Docker only resolves names to stages defined earlier in the file, so
3222 // resolving strictly backwards is correct and cannot cycle.
3223 let mut index = start_index;
3224 loop {
3225 let image = stages.get(index)?.image;
3226 let previous_stage = stages.get(..index)?.iter().rposition(|stage| {
3227 stage
3228 .alias
3229 .is_some_and(|alias| alias.eq_ignore_ascii_case(image))
3230 });
3231 match previous_stage {
3232 Some(previous_index) => index = previous_index,
3233 None => return Some(image.to_string()),
3234 }
3235 }
3236}
3237
3238fn get_remote_user_from_config(
3239 docker_config: &DockerInspect,
3240 devcontainer: &DevContainerManifest,
3241) -> Result<String, DevContainerError> {
3242 if let DevContainer {
3243 remote_user: Some(user),
3244 ..
3245 } = &devcontainer.dev_container()
3246 {
3247 return Ok(user.clone());
3248 }
3249 if let Some(metadata) = &docker_config.config.labels.metadata {
3250 for metadatum in metadata {
3251 if let Some(remote_user) = metadatum.get("remoteUser") {
3252 if let Some(remote_user_str) = remote_user.as_str() {
3253 return Ok(remote_user_str.to_string());
3254 }
3255 }
3256 }
3257 }
3258 if let Some(image_user) = &docker_config.config.image_user {
3259 if !image_user.is_empty() {
3260 return Ok(image_user.to_string());
3261 }
3262 }
3263 Ok("root".to_string())
3264}
3265
3266// This should come from spec - see the docs
3267fn get_container_user_from_config(
3268 docker_config: &DockerInspect,
3269 devcontainer: &DevContainerManifest,
3270) -> Result<String, DevContainerError> {
3271 if let Some(user) = &devcontainer.dev_container().container_user {
3272 return Ok(user.to_string());
3273 }
3274 if let Some(metadata) = &docker_config.config.labels.metadata {
3275 for metadatum in metadata {
3276 if let Some(container_user) = metadatum.get("containerUser") {
3277 if let Some(container_user_str) = container_user.as_str() {
3278 return Ok(container_user_str.to_string());
3279 }
3280 }
3281 }
3282 }
3283 if let Some(image_user) = &docker_config.config.image_user {
3284 return Ok(image_user.to_string());
3285 }
3286
3287 Ok("root".to_string())
3288}
3289
3290fn push_unique_string(values: &mut Vec<String>, value: &str) {
3291 if !values.iter().any(|existing| existing == value) {
3292 values.push(value.to_string());
3293 }
3294}
3295
3296fn append_mount_with_target_override(mounts: &mut Vec<MountDefinition>, mount: MountDefinition) {
3297 mounts.retain(|existing| existing.target != mount.target);
3298 mounts.push(mount);
3299}
3300
3301fn mount_definition_from_metadata(value: &serde_json_lenient::Value) -> Option<MountDefinition> {
3302 match value {
3303 serde_json_lenient::Value::String(value) => parse_mount_definition_string(value),
3304 serde_json_lenient::Value::Object(object) => {
3305 let source = object
3306 .get("source")
3307 .or_else(|| object.get("src"))
3308 .and_then(|value| value.as_str())
3309 .map(str::to_string);
3310 let target = object
3311 .get("target")
3312 .or_else(|| object.get("dst"))
3313 .or_else(|| object.get("destination"))
3314 .and_then(|value| value.as_str())?
3315 .to_string();
3316 let mount_type = object
3317 .get("type")
3318 .and_then(|value| value.as_str())
3319 .map(str::to_string);
3320 Some(MountDefinition {
3321 source,
3322 target,
3323 mount_type,
3324 })
3325 }
3326 _ => None,
3327 }
3328}
3329
3330fn parse_mount_definition_string(value: &str) -> Option<MountDefinition> {
3331 let mut source = None;
3332 let mut target = None;
3333 let mut mount_type = None;
3334
3335 for part in value.split(',') {
3336 let Some((key, value)) = part.trim().split_once('=') else {
3337 continue;
3338 };
3339 match key.trim() {
3340 "source" | "src" => source = Some(value.trim().to_string()),
3341 "target" | "dst" | "destination" => target = Some(value.trim().to_string()),
3342 "type" => mount_type = Some(value.trim().to_string()),
3343 _ => {}
3344 }
3345 }
3346
3347 target.map(|target| MountDefinition {
3348 source,
3349 target,
3350 mount_type,
3351 })
3352}
3353
3354fn escape_compose_interpolation(value: &str) -> String {
3355 value.replace('$', "$$")
3356}
3357
3358fn shell_single_quote(value: &str) -> String {
3359 format!("'{}'", value.replace('\'', "'\\''"))
3360}
3361
3362fn command_to_shell_string(command: &Command) -> String {
3363 let mut command_parts = vec![command.get_program().display().to_string()];
3364 command_parts.extend(command.get_args().map(|arg| arg.display().to_string()));
3365 command_parts.join(" ")
3366}
3367
3368fn post_start_marker_script(started_at: &str, script_commands: HashMap<String, Command>) -> String {
3369 let started_at = shell_single_quote(started_at);
3370 let mut script = format!(
3371 r#"user_id="$(id -u)"
3372home_directory="${{HOME:-}}"
3373if [ -z "$home_directory" ]; then
3374 home_directory="$( (command -v getent >/dev/null 2>&1 && getent passwd "$user_id" || grep -E "^[^:]*:[^:]*:${{user_id}}:" /etc/passwd || true) | head -n 1 | cut -d: -f6)"
3375fi
3376if [ -z "$home_directory" ]; then
3377 home_directory=/root
3378fi
3379marker_directory="$home_directory/.devcontainer"
3380marker="$marker_directory/.postStartCommandMarker"
3381started_at={started_at}
3382marker_available=false
3383if mkdir -p "$marker_directory"; then
3384 previous_started_at="$(cat "$marker" 2>/dev/null || true)"
3385 if [ "$previous_started_at" = "$started_at" ]; then
3386 exit 0
3387 fi
3388 marker_available=true
3389fi
3390"#,
3391 );
3392 for command in script_commands.into_values() {
3393 script.push_str(&command_to_shell_string(&command));
3394 script.push_str("\ncommand_status=$?\n");
3395 script.push_str("[ \"$command_status\" -eq 0 ] || exit \"$command_status\"\n");
3396 }
3397 script.push_str(
3398 r#"if [ "$marker_available" = "true" ]; then
3399 printf '%s' "$started_at" > "$marker" || exit $?
3400fi"#,
3401 );
3402 script
3403}
3404
3405fn build_devcontainer_metadata_entry(
3406 raw_json: &serde_json_lenient::Value,
3407) -> serde_json_lenient::Map<String, serde_json_lenient::Value> {
3408 use serde_json_lenient::Value;
3409 static METADATA_PROPERTIES: &[&str] = &[
3410 "onCreateCommand",
3411 "updateContentCommand",
3412 "postCreateCommand",
3413 "postStartCommand",
3414 "postAttachCommand",
3415 "waitFor",
3416 "customizations",
3417 "mounts",
3418 "containerEnv",
3419 "containerUser",
3420 "init",
3421 "privileged",
3422 "capAdd",
3423 "securityOpt",
3424 "remoteUser",
3425 "userEnvProbe",
3426 "remoteEnv",
3427 "overrideCommand",
3428 "portsAttributes",
3429 "otherPortsAttributes",
3430 "forwardPorts",
3431 "shutdownAction",
3432 "updateRemoteUserUID",
3433 "hostRequirements",
3434 ];
3435
3436 let Value::Object(full) = raw_json else {
3437 return serde_json_lenient::Map::new();
3438 };
3439
3440 full.iter()
3441 .filter(|(key, value)| METADATA_PROPERTIES.contains(&key.as_str()) && !value.is_null())
3442 .map(|(key, value)| (key.clone(), value.clone()))
3443 .collect()
3444}
3445
3446fn normalize_label_path(path: &str) -> String {
3447 #[cfg(not(target_os = "windows"))]
3448 {
3449 path.to_string()
3450 }
3451 #[cfg(target_os = "windows")]
3452 {
3453 let normalized = path.replace('/', "\\");
3454 if normalized.len() >= 2 && normalized.as_bytes()[1] == b':' {
3455 let mut result = normalized[..1].to_lowercase();
3456 result.push_str(&normalized[1..]);
3457 result
3458 } else {
3459 normalized
3460 }
3461 }
3462}
3463
3464#[cfg(test)]
3465mod test {
3466 use std::{
3467 collections::HashMap,
3468 ffi::OsStr,
3469 path::{Path, PathBuf},
3470 process::{ExitStatus, Output},
3471 sync::{Arc, Mutex},
3472 };
3473
3474 #[cfg(not(target_os = "windows"))]
3475 use std::{
3476 fs as std_fs,
3477 time::{SystemTime, UNIX_EPOCH},
3478 };
3479
3480 use async_trait::async_trait;
3481 use fs::{FakeFs, Fs};
3482 use gpui::{AppContext, TestAppContext};
3483 use http_client::{AsyncBody, FakeHttpClient, HttpClient};
3484 use indoc::indoc;
3485 use project::{
3486 ProjectEnvironment,
3487 worktree_store::{WorktreeIdCounter, WorktreeStore},
3488 };
3489 use serde_json_lenient::Value;
3490 use util::{command::Command, paths::SanitizedPath};
3491
3492 use crate::{
3493 DevContainerConfig, DevContainerContext,
3494 command_json::CommandRunner,
3495 devcontainer_api::{DevContainerError, DevContainerUp},
3496 devcontainer_json::MountDefinition,
3497 devcontainer_manifest::{
3498 ConfigStatus, DevContainerManifest, DockerBuildResources, DockerComposeResources,
3499 DockerInspect, dockerfile_inject_alias, escape_compose_interpolation,
3500 extract_feature_id, find_primary_service, get_remote_user_from_config,
3501 image_from_dockerfile, is_local_feature_ref, resolve_compose_dockerfile,
3502 },
3503 docker::{
3504 DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild,
3505 DockerComposeVolume, DockerConfigLabels, DockerInspectConfig, DockerInspectMount,
3506 DockerPs,
3507 },
3508 oci::TokenResponse,
3509 };
3510 #[cfg(not(target_os = "windows"))]
3511 const TEST_PROJECT_PATH: &str = "/path/to/local/project";
3512 #[cfg(target_os = "windows")]
3513 const TEST_PROJECT_PATH: &str = r#"C:\\path\to\local\project"#;
3514
3515 async fn build_tarball(content: Vec<(&str, &str)>) -> Vec<u8> {
3516 let buffer = futures::io::Cursor::new(Vec::new());
3517 let mut builder = async_tar::Builder::new(buffer);
3518 for (file_name, content) in content {
3519 if content.is_empty() {
3520 let mut header = async_tar::Header::new_gnu();
3521 header.set_size(0);
3522 header.set_mode(0o755);
3523 header.set_entry_type(async_tar::EntryType::Directory);
3524 header.set_cksum();
3525 builder
3526 .append_data(&mut header, file_name, &[] as &[u8])
3527 .await
3528 .unwrap();
3529 } else {
3530 let data = content.as_bytes();
3531 let mut header = async_tar::Header::new_gnu();
3532 header.set_size(data.len() as u64);
3533 header.set_mode(0o755);
3534 header.set_entry_type(async_tar::EntryType::Regular);
3535 header.set_cksum();
3536 builder
3537 .append_data(&mut header, file_name, data)
3538 .await
3539 .unwrap();
3540 }
3541 }
3542 let buffer = builder.into_inner().await.unwrap();
3543 buffer.into_inner()
3544 }
3545
3546 fn test_project_filename() -> String {
3547 PathBuf::from(TEST_PROJECT_PATH)
3548 .file_name()
3549 .expect("is valid")
3550 .display()
3551 .to_string()
3552 }
3553
3554 async fn init_devcontainer_config(
3555 fs: &Arc<FakeFs>,
3556 devcontainer_contents: &str,
3557 ) -> DevContainerConfig {
3558 fs.insert_tree(
3559 format!("{TEST_PROJECT_PATH}/.devcontainer"),
3560 serde_json::json!({"devcontainer.json": devcontainer_contents}),
3561 )
3562 .await;
3563
3564 DevContainerConfig::default_config()
3565 }
3566
3567 struct TestDependencies {
3568 fs: Arc<FakeFs>,
3569 _http_client: Arc<dyn HttpClient>,
3570 docker: Arc<FakeDocker>,
3571 command_runner: Arc<TestCommandRunner>,
3572 }
3573
3574 async fn init_default_devcontainer_manifest(
3575 cx: &mut TestAppContext,
3576 devcontainer_contents: &str,
3577 ) -> Result<(TestDependencies, DevContainerManifest), DevContainerError> {
3578 let fs = FakeFs::new(cx.executor());
3579 let http_client = fake_http_client();
3580 let command_runner = Arc::new(TestCommandRunner::new());
3581 let docker = Arc::new(FakeDocker::new());
3582 let environment = HashMap::new();
3583
3584 init_devcontainer_manifest(
3585 cx,
3586 fs,
3587 http_client,
3588 docker,
3589 command_runner,
3590 environment,
3591 devcontainer_contents,
3592 )
3593 .await
3594 }
3595
3596 async fn init_devcontainer_manifest(
3597 cx: &mut TestAppContext,
3598 fs: Arc<FakeFs>,
3599 http_client: Arc<dyn HttpClient>,
3600 docker_client: Arc<FakeDocker>,
3601 command_runner: Arc<TestCommandRunner>,
3602 environment: HashMap<String, String>,
3603 devcontainer_contents: &str,
3604 ) -> Result<(TestDependencies, DevContainerManifest), DevContainerError> {
3605 let local_config = init_devcontainer_config(&fs, devcontainer_contents).await;
3606 let project_path = SanitizedPath::new_arc(&PathBuf::from(TEST_PROJECT_PATH));
3607 let worktree_store =
3608 cx.new(|_cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::default()));
3609 let project_environment =
3610 cx.new(|cx| ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx));
3611
3612 let context = DevContainerContext {
3613 project_directory: SanitizedPath::cast_arc(project_path),
3614 use_podman: false,
3615 use_buildkit: None,
3616 fs: fs.clone(),
3617 http_client: http_client.clone(),
3618 environment: project_environment.downgrade(),
3619 };
3620
3621 let test_dependencies = TestDependencies {
3622 fs: fs.clone(),
3623 _http_client: http_client.clone(),
3624 docker: docker_client.clone(),
3625 command_runner: command_runner.clone(),
3626 };
3627 let manifest = DevContainerManifest::new(
3628 &context,
3629 environment,
3630 docker_client,
3631 command_runner,
3632 local_config,
3633 &PathBuf::from(TEST_PROJECT_PATH),
3634 )
3635 .await?;
3636
3637 Ok((test_dependencies, manifest))
3638 }
3639
3640 #[gpui::test]
3641 async fn should_get_remote_user_from_devcontainer_if_available(cx: &mut TestAppContext) {
3642 let (_, devcontainer_manifest) = init_default_devcontainer_manifest(
3643 cx,
3644 r#"
3645// These are some external comments. serde_lenient should handle them
3646{
3647 // These are some internal comments
3648 "image": "image",
3649 "remoteUser": "root",
3650}
3651 "#,
3652 )
3653 .await
3654 .unwrap();
3655
3656 let mut metadata = HashMap::new();
3657 metadata.insert(
3658 "remoteUser".to_string(),
3659 serde_json_lenient::Value::String("vsCode".to_string()),
3660 );
3661 let given_docker_config = DockerInspect {
3662 id: "docker_id".to_string(),
3663 config: DockerInspectConfig {
3664 labels: DockerConfigLabels {
3665 metadata: Some(vec![metadata]),
3666 },
3667 image_user: None,
3668 env: Vec::new(),
3669 },
3670 mounts: None,
3671 state: None,
3672 };
3673
3674 let remote_user =
3675 get_remote_user_from_config(&given_docker_config, &devcontainer_manifest).unwrap();
3676
3677 assert_eq!(remote_user, "root".to_string())
3678 }
3679
3680 #[gpui::test]
3681 async fn should_get_remote_user_from_docker_config(cx: &mut TestAppContext) {
3682 let (_, devcontainer_manifest) =
3683 init_default_devcontainer_manifest(cx, "{}").await.unwrap();
3684 let mut metadata = HashMap::new();
3685 metadata.insert(
3686 "remoteUser".to_string(),
3687 serde_json_lenient::Value::String("vsCode".to_string()),
3688 );
3689 let given_docker_config = DockerInspect {
3690 id: "docker_id".to_string(),
3691 config: DockerInspectConfig {
3692 labels: DockerConfigLabels {
3693 metadata: Some(vec![metadata]),
3694 },
3695 image_user: None,
3696 env: Vec::new(),
3697 },
3698 mounts: None,
3699 state: None,
3700 };
3701
3702 let remote_user = get_remote_user_from_config(&given_docker_config, &devcontainer_manifest);
3703
3704 assert!(remote_user.is_ok());
3705 let remote_user = remote_user.expect("ok");
3706 assert_eq!(&remote_user, "vsCode")
3707 }
3708
3709 #[test]
3710 fn should_extract_feature_id_from_references() {
3711 assert_eq!(
3712 extract_feature_id("ghcr.io/devcontainers/features/aws-cli:1"),
3713 "aws-cli"
3714 );
3715 assert_eq!(
3716 extract_feature_id("ghcr.io/devcontainers/features/go"),
3717 "go"
3718 );
3719 assert_eq!(extract_feature_id("ghcr.io/user/repo/node:18.0.0"), "node");
3720 assert_eq!(extract_feature_id("./myFeature"), "myFeature");
3721 assert_eq!(
3722 extract_feature_id("ghcr.io/devcontainers/features/rust@sha256:abc123"),
3723 "rust"
3724 );
3725 }
3726
3727 #[test]
3728 fn should_identify_local_feature_refs() {
3729 assert!(is_local_feature_ref("./lsp-devtools"));
3730 assert!(is_local_feature_ref("./some/nested/feature"));
3731 assert!(is_local_feature_ref("../sibling-feature"));
3732 assert!(!is_local_feature_ref("ghcr.io/devcontainers/features/go:1"));
3733 assert!(!is_local_feature_ref("ghcr.io/user/repo/node:18.0.0"));
3734 assert!(!is_local_feature_ref("https://example.com/feature.tgz"));
3735 }
3736
3737 #[gpui::test]
3738 async fn should_create_correct_docker_run_command(cx: &mut TestAppContext) {
3739 let mut metadata = HashMap::new();
3740 metadata.insert(
3741 "remoteUser".to_string(),
3742 serde_json_lenient::Value::String("vsCode".to_string()),
3743 );
3744
3745 let (_, devcontainer_manifest) = init_default_devcontainer_manifest(
3746 cx,
3747 r#"{
3748 "name": "TODO"
3749 }"#,
3750 )
3751 .await
3752 .unwrap();
3753 let build_resources = DockerBuildResources {
3754 image: DockerInspect {
3755 id: "mcr.microsoft.com/devcontainers/base:ubuntu".to_string(),
3756 config: DockerInspectConfig {
3757 labels: DockerConfigLabels {
3758 metadata: None,
3759 },
3760 image_user: None,
3761 env: Vec::new(),
3762 },
3763 mounts: None,
3764 state: None,
3765 },
3766 image_tag: "mcr.microsoft.com/devcontainers/base:ubuntu".to_string(),
3767 additional_mounts: vec![],
3768 container_env: HashMap::new(),
3769 privileged: false,
3770 init: false,
3771 cap_add: vec!["SYS_PTRACE".to_string()],
3772 security_opt: vec!["seccomp=unconfined".to_string()],
3773 entrypoint_script: Some("echo Container started\n trap \"exit 0\" 15\n exec \"$@\"\n while sleep 1 & wait $!; do :; done".to_string()),
3774 };
3775 let docker_run_command = devcontainer_manifest.create_docker_run_command(build_resources);
3776
3777 assert!(docker_run_command.is_ok());
3778 let docker_run_command = docker_run_command.expect("ok");
3779
3780 assert_eq!(docker_run_command.get_program(), "docker");
3781 let expected_local_folder_label = format!(
3782 "devcontainer.local_folder={}",
3783 super::normalize_label_path(TEST_PROJECT_PATH)
3784 );
3785 let expected_config_file_path = PathBuf::from(TEST_PROJECT_PATH)
3786 .join(".devcontainer")
3787 .join("devcontainer.json");
3788 let expected_config_file_label = format!(
3789 "devcontainer.config_file={}",
3790 super::normalize_label_path(&expected_config_file_path.display().to_string())
3791 );
3792 assert_eq!(
3793 docker_run_command.get_args().collect::<Vec<&OsStr>>(),
3794 vec![
3795 OsStr::new("run"),
3796 OsStr::new("--sig-proxy=false"),
3797 OsStr::new("-d"),
3798 OsStr::new("--mount"),
3799 OsStr::new(&format!(
3800 "type=bind,source={TEST_PROJECT_PATH},target=/workspaces/project,consistency=cached"
3801 )),
3802 OsStr::new("-l"),
3803 OsStr::new(&expected_local_folder_label),
3804 OsStr::new("-l"),
3805 OsStr::new(&expected_config_file_label),
3806 OsStr::new("--cap-add"),
3807 OsStr::new("SYS_PTRACE"),
3808 OsStr::new("--security-opt"),
3809 OsStr::new("seccomp=unconfined"),
3810 OsStr::new("--entrypoint"),
3811 OsStr::new("/bin/sh"),
3812 OsStr::new("mcr.microsoft.com/devcontainers/base:ubuntu"),
3813 OsStr::new("-c"),
3814 OsStr::new(
3815 "
3816 echo Container started
3817 trap \"exit 0\" 15
3818 exec \"$@\"
3819 while sleep 1 & wait $!; do :; done
3820 "
3821 .trim()
3822 ),
3823 OsStr::new("-"),
3824 ]
3825 )
3826 }
3827
3828 #[gpui::test]
3829 async fn should_not_override_entrypoint_when_override_command_is_false(
3830 cx: &mut TestAppContext,
3831 ) {
3832 let (_, mut devcontainer_manifest) = init_default_devcontainer_manifest(
3833 cx,
3834 r#"{
3835 "name": "test",
3836 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
3837 "overrideCommand": false
3838 }"#,
3839 )
3840 .await
3841 .unwrap();
3842
3843 devcontainer_manifest.parse_nonremote_vars().unwrap();
3844
3845 let base_image = DockerInspect {
3846 id: "mcr.microsoft.com/devcontainers/base:ubuntu".to_string(),
3847 config: DockerInspectConfig {
3848 labels: DockerConfigLabels { metadata: None },
3849 image_user: None,
3850 env: Vec::new(),
3851 },
3852 mounts: None,
3853 state: None,
3854 };
3855
3856 let resources = devcontainer_manifest
3857 .build_merged_resources(base_image, "mcr.microsoft.com/devcontainers/base:ubuntu")
3858 .unwrap();
3859 assert!(
3860 resources.entrypoint_script.is_none(),
3861 "overrideCommand: false must not produce an entrypoint script"
3862 );
3863
3864 let docker_run_command = devcontainer_manifest
3865 .create_docker_run_command(resources)
3866 .unwrap();
3867 let args: Vec<&OsStr> = docker_run_command.get_args().collect();
3868 assert!(
3869 !args.contains(&OsStr::new("--entrypoint")),
3870 "overrideCommand: false must not pass --entrypoint to docker run"
3871 );
3872 assert!(
3873 args.contains(&OsStr::new("mcr.microsoft.com/devcontainers/base:ubuntu")),
3874 "image id must still be present in docker run command"
3875 );
3876 }
3877
3878 #[gpui::test]
3879 async fn should_substitute_nonremote_variables_in_image_metadata(cx: &mut TestAppContext) {
3880 let (_, mut devcontainer_manifest) = init_default_devcontainer_manifest(
3881 cx,
3882 r#"{
3883 "image": "mcr.microsoft.com/devcontainers/base:ubuntu"
3884 }"#,
3885 )
3886 .await
3887 .unwrap();
3888 devcontainer_manifest.parse_nonremote_vars().unwrap();
3889
3890 let metadata = serde_json_lenient::from_str(
3891 r#"[
3892 {
3893 "id": "ghcr.io/devcontainers/features/docker-in-docker:2",
3894 "mounts": [
3895 {
3896 "source": "dind-var-lib-docker-${devcontainerId}",
3897 "target": "/var/lib/docker",
3898 "type": "volume"
3899 }
3900 ]
3901 },
3902 {
3903 "containerEnv": {
3904 "PATH": "/custom/bin:${PATH}"
3905 }
3906 }
3907 ]"#,
3908 )
3909 .unwrap();
3910 let base_image = DockerInspect {
3911 id: "base-image-id".to_string(),
3912 config: DockerInspectConfig {
3913 labels: DockerConfigLabels {
3914 metadata: Some(metadata),
3915 },
3916 image_user: None,
3917 env: vec!["PATH=/usr/local/bin:/usr/bin".to_string()],
3918 },
3919 mounts: None,
3920 state: None,
3921 };
3922
3923 let resources = devcontainer_manifest
3924 .build_merged_resources(base_image, "mcr.microsoft.com/devcontainers/base:ubuntu")
3925 .unwrap();
3926
3927 assert_eq!(
3928 resources.additional_mounts,
3929 vec![MountDefinition {
3930 source: Some(format!(
3931 "dind-var-lib-docker-{}",
3932 devcontainer_manifest.devcontainer_id()
3933 )),
3934 target: "/var/lib/docker".to_string(),
3935 mount_type: Some("volume".to_string()),
3936 }]
3937 );
3938 assert_eq!(
3939 resources.container_env.get("PATH").map(String::as_str),
3940 Some("/custom/bin:${PATH}")
3941 );
3942 let persisted_mount_source = resources
3943 .image
3944 .config
3945 .labels
3946 .metadata
3947 .as_ref()
3948 .and_then(|entries| entries.first())
3949 .and_then(|entry| entry.get("mounts"))
3950 .and_then(|mounts| mounts.as_array())
3951 .and_then(|mounts| mounts.first())
3952 .and_then(|mount| mount.get("source"))
3953 .and_then(|source| source.as_str());
3954 assert_eq!(
3955 persisted_mount_source,
3956 Some("dind-var-lib-docker-${devcontainerId}")
3957 );
3958 }
3959
3960 #[gpui::test]
3961 async fn should_use_devcontainer_cli_post_start_marker_for_started_container(
3962 cx: &mut TestAppContext,
3963 ) {
3964 let (test_dependencies, mut devcontainer_manifest) = init_default_devcontainer_manifest(
3965 cx,
3966 r#"{
3967 "image": "test_image:latest",
3968 "postStartCommand": "echo post-start",
3969 "postAttachCommand": "echo post-attach"
3970 }"#,
3971 )
3972 .await
3973 .unwrap();
3974 devcontainer_manifest.parse_nonremote_vars().unwrap();
3975
3976 let devcontainer_up = DevContainerUp {
3977 started_at: Some("2026-06-23T10:00:00Z".to_string()),
3978 container_id: "container".to_string(),
3979 remote_user: "root".to_string(),
3980 remote_workspace_folder: "/workspaces/project".to_string(),
3981 extension_ids: Vec::new(),
3982 remote_env: HashMap::new(),
3983 };
3984
3985 devcontainer_manifest
3986 .run_remote_scripts(&devcontainer_up, false, false)
3987 .await
3988 .unwrap();
3989
3990 let docker_exec_commands = test_dependencies
3991 .docker
3992 .exec_commands_recorded
3993 .lock()
3994 .unwrap();
3995 assert_eq!(docker_exec_commands.len(), 2);
3996 let post_start_script = docker_exec_commands[0]
3997 ._inner_command
3998 .get_program()
3999 .to_string_lossy();
4000 assert!(post_start_script.contains("marker_directory=\"$home_directory/.devcontainer\""));
4001 assert!(post_start_script.contains("marker=\"$marker_directory/.postStartCommandMarker\""));
4002 assert!(!post_start_script.contains("/tmp/zed-devcontainer"));
4003 assert!(post_start_script.contains(
4004 "echo post-start\ncommand_status=$?\n[ \"$command_status\" -eq 0 ] || exit \"$command_status\""
4005 ));
4006 assert!(
4007 post_start_script.find("echo post-start").unwrap()
4008 < post_start_script.find("printf '%s'").unwrap(),
4009 "postStartCommand marker must be written only after the command succeeds"
4010 );
4011 assert_eq!(docker_exec_commands[1]._inner_command.get_program(), "echo");
4012 assert_eq!(
4013 docker_exec_commands[1]
4014 ._inner_command
4015 .get_args()
4016 .collect::<Vec<_>>(),
4017 vec![OsStr::new("post-attach")]
4018 );
4019 }
4020
4021 #[cfg(not(target_os = "windows"))]
4022 #[test]
4023 fn post_start_marker_script_runs_command_when_marker_directory_cannot_be_created() {
4024 let mut command = Command::new("echo");
4025 command.arg("post-start");
4026 let script = super::post_start_marker_script(
4027 "2026-06-23T10:00:00Z",
4028 HashMap::from([("default".to_string(), command)]),
4029 );
4030
4031 let output = run_shell_script(&script, Path::new("/dev/null"));
4032
4033 assert!(
4034 output.status.success(),
4035 "script should not fail when marker directory cannot be created: {}",
4036 String::from_utf8_lossy(&output.stderr)
4037 );
4038 assert_eq!(String::from_utf8_lossy(&output.stdout), "post-start\n");
4039 }
4040
4041 #[cfg(not(target_os = "windows"))]
4042 #[test]
4043 fn post_start_marker_script_accepts_background_command() {
4044 let home_directory = temporary_home_directory("post-start-background");
4045 let started_at = "2026-06-23T10:00:00Z";
4046 let marker = home_directory
4047 .join(".devcontainer")
4048 .join(".postStartCommandMarker");
4049 let mut command = Command::new("true");
4050 command.arg("&");
4051 let script = super::post_start_marker_script(
4052 started_at,
4053 HashMap::from([("default".to_string(), command)]),
4054 );
4055
4056 let output = run_shell_script(&script, &home_directory);
4057
4058 assert!(
4059 output.status.success(),
4060 "background command should be accepted: {}",
4061 String::from_utf8_lossy(&output.stderr)
4062 );
4063 assert_eq!(
4064 std_fs::read_to_string(&marker).expect("marker should be written"),
4065 started_at
4066 );
4067
4068 std_fs::remove_dir_all(home_directory).expect("temporary home should be removed");
4069 }
4070
4071 #[cfg(not(target_os = "windows"))]
4072 #[test]
4073 fn post_start_marker_script_marks_only_after_success() {
4074 let home_directory = temporary_home_directory("post-start-success");
4075 let started_at = "2026-06-23T10:00:00Z";
4076 let marker = home_directory
4077 .join(".devcontainer")
4078 .join(".postStartCommandMarker");
4079 let mut command = Command::new("echo");
4080 command.arg("post-start");
4081 let script = super::post_start_marker_script(
4082 started_at,
4083 HashMap::from([("default".to_string(), command)]),
4084 );
4085
4086 let output = run_shell_script(&script, &home_directory);
4087 assert!(
4088 output.status.success(),
4089 "script should run successfully: {}",
4090 String::from_utf8_lossy(&output.stderr)
4091 );
4092 assert_eq!(String::from_utf8_lossy(&output.stdout), "post-start\n");
4093 assert_eq!(
4094 std_fs::read_to_string(&marker).expect("marker should be written"),
4095 started_at
4096 );
4097
4098 let output = run_shell_script(&script, &home_directory);
4099 assert!(
4100 output.status.success(),
4101 "script should skip successfully: {}",
4102 String::from_utf8_lossy(&output.stderr)
4103 );
4104 assert_eq!(String::from_utf8_lossy(&output.stdout), "");
4105
4106 std_fs::remove_dir_all(home_directory).expect("temporary home should be removed");
4107 }
4108
4109 #[cfg(not(target_os = "windows"))]
4110 #[test]
4111 fn post_start_marker_script_surfaces_marker_write_failure() {
4112 let home_directory = temporary_home_directory("post-start-marker-write-failure");
4113 let marker = home_directory
4114 .join(".devcontainer")
4115 .join(".postStartCommandMarker");
4116 std_fs::create_dir_all(&marker).expect("marker path should be a directory");
4117 let mut command = Command::new("echo");
4118 command.arg("post-start");
4119 let script = super::post_start_marker_script(
4120 "2026-06-23T10:00:00Z",
4121 HashMap::from([("default".to_string(), command)]),
4122 );
4123
4124 let output = run_shell_script(&script, &home_directory);
4125
4126 assert!(!output.status.success());
4127 assert_eq!(String::from_utf8_lossy(&output.stdout), "post-start\n");
4128 assert!(marker.is_dir());
4129
4130 std_fs::remove_dir_all(home_directory).expect("temporary home should be removed");
4131 }
4132
4133 #[cfg(not(target_os = "windows"))]
4134 #[test]
4135 fn post_start_marker_script_does_not_mark_after_failure() {
4136 let home_directory = temporary_home_directory("post-start-failure");
4137 let marker = home_directory
4138 .join(".devcontainer")
4139 .join(".postStartCommandMarker");
4140 let script = super::post_start_marker_script(
4141 "2026-06-23T10:00:00Z",
4142 HashMap::from([("default".to_string(), Command::new("false"))]),
4143 );
4144
4145 let output = run_shell_script(&script, &home_directory);
4146
4147 assert!(!output.status.success());
4148 assert!(!marker.exists());
4149
4150 std_fs::remove_dir_all(home_directory).expect("temporary home should be removed");
4151 }
4152
4153 #[cfg(not(target_os = "windows"))]
4154 fn run_shell_script(script: &str, home_directory: &Path) -> Output {
4155 let mut command = Command::new("sh");
4156 command.arg("-c").arg(script).env("HOME", home_directory);
4157 gpui::block_on(command.output()).expect("shell should run postStartCommand marker script")
4158 }
4159
4160 #[cfg(not(target_os = "windows"))]
4161 fn temporary_home_directory(prefix: &str) -> PathBuf {
4162 let now = SystemTime::now()
4163 .duration_since(UNIX_EPOCH)
4164 .expect("system time should be after unix epoch")
4165 .as_nanos();
4166 let path = std::env::temp_dir().join(format!(
4167 "zed-devcontainer-{prefix}-{}-{now}",
4168 std::process::id()
4169 ));
4170 std_fs::create_dir_all(&path).expect("temporary home should be created");
4171 path
4172 }
4173
4174 #[gpui::test]
4175 async fn should_find_primary_service_in_docker_compose(cx: &mut TestAppContext) {
4176 // State where service not defined in dev container
4177 let (_, given_dev_container) = init_default_devcontainer_manifest(cx, "{}").await.unwrap();
4178 let given_docker_compose_config = DockerComposeResources {
4179 config: DockerComposeConfig {
4180 name: Some("devcontainers".to_string()),
4181 services: HashMap::new(),
4182 ..Default::default()
4183 },
4184 ..Default::default()
4185 };
4186
4187 let bad_result = find_primary_service(&given_docker_compose_config, &given_dev_container);
4188
4189 assert!(bad_result.is_err());
4190
4191 // State where service defined in devcontainer, not found in DockerCompose config
4192 let (_, given_dev_container) =
4193 init_default_devcontainer_manifest(cx, r#"{"service": "not_found_service"}"#)
4194 .await
4195 .unwrap();
4196 let given_docker_compose_config = DockerComposeResources {
4197 config: DockerComposeConfig {
4198 name: Some("devcontainers".to_string()),
4199 services: HashMap::new(),
4200 ..Default::default()
4201 },
4202 ..Default::default()
4203 };
4204
4205 let bad_result = find_primary_service(&given_docker_compose_config, &given_dev_container);
4206
4207 assert!(bad_result.is_err());
4208 // State where service defined in devcontainer and in DockerCompose config
4209
4210 let (_, given_dev_container) =
4211 init_default_devcontainer_manifest(cx, r#"{"service": "found_service"}"#)
4212 .await
4213 .unwrap();
4214 let given_docker_compose_config = DockerComposeResources {
4215 config: DockerComposeConfig {
4216 name: Some("devcontainers".to_string()),
4217 services: HashMap::from([(
4218 "found_service".to_string(),
4219 DockerComposeService {
4220 ..Default::default()
4221 },
4222 )]),
4223 ..Default::default()
4224 },
4225 ..Default::default()
4226 };
4227
4228 let (service_name, _) =
4229 find_primary_service(&given_docker_compose_config, &given_dev_container).unwrap();
4230
4231 assert_eq!(service_name, "found_service".to_string());
4232 }
4233
4234 #[gpui::test]
4235 async fn test_nonremote_variable_replacement_with_default_mount(cx: &mut TestAppContext) {
4236 let fs = FakeFs::new(cx.executor());
4237 let given_devcontainer_contents = r#"
4238// These are some external comments. serde_lenient should handle them
4239{
4240 // These are some internal comments
4241 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
4242 "name": "myDevContainer-${devcontainerId}",
4243 "remoteUser": "root",
4244 "remoteEnv": {
4245 "DEVCONTAINER_ID": "${devcontainerId}",
4246 "MYVAR2": "myvarothervalue",
4247 "REMOTE_WORKSPACE_FOLDER_BASENAME": "${containerWorkspaceFolderBasename}",
4248 "LOCAL_WORKSPACE_FOLDER_BASENAME": "${localWorkspaceFolderBasename}",
4249 "REMOTE_WORKSPACE_FOLDER": "${containerWorkspaceFolder}",
4250 "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}",
4251 "LOCAL_ENV_VAR_1": "${localEnv:local_env_1}",
4252 "LOCAL_ENV_VAR_2": "${localEnv:my_other_env}",
4253 "LOCAL_ENV_VAR_3": "before-${localEnv:missing_local_env}-after",
4254 "LOCAL_ENV_VAR_4": "${localEnv:with_defaults:default}"
4255
4256 }
4257}
4258 "#;
4259 let (_, mut devcontainer_manifest) = init_devcontainer_manifest(
4260 cx,
4261 fs,
4262 fake_http_client(),
4263 Arc::new(FakeDocker::new()),
4264 Arc::new(TestCommandRunner::new()),
4265 HashMap::from([
4266 ("local_env_1".to_string(), "local_env_value1".to_string()),
4267 ("my_other_env".to_string(), "THISVALUEHERE".to_string()),
4268 ]),
4269 given_devcontainer_contents,
4270 )
4271 .await
4272 .unwrap();
4273
4274 devcontainer_manifest.parse_nonremote_vars().unwrap();
4275
4276 let ConfigStatus::VariableParsed(variable_replaced_devcontainer) =
4277 &devcontainer_manifest.config
4278 else {
4279 panic!("Config not parsed");
4280 };
4281
4282 // ${devcontainerId}
4283 let devcontainer_id = devcontainer_manifest.devcontainer_id();
4284 assert_eq!(
4285 variable_replaced_devcontainer.name,
4286 Some(format!("myDevContainer-{devcontainer_id}"))
4287 );
4288 assert_eq!(
4289 variable_replaced_devcontainer
4290 .remote_env
4291 .as_ref()
4292 .and_then(|env| env.get("DEVCONTAINER_ID")),
4293 Some(&devcontainer_id)
4294 );
4295
4296 // ${containerWorkspaceFolderBasename}
4297 assert_eq!(
4298 variable_replaced_devcontainer
4299 .remote_env
4300 .as_ref()
4301 .and_then(|env| env.get("REMOTE_WORKSPACE_FOLDER_BASENAME")),
4302 Some(&test_project_filename())
4303 );
4304
4305 // ${localWorkspaceFolderBasename}
4306 assert_eq!(
4307 variable_replaced_devcontainer
4308 .remote_env
4309 .as_ref()
4310 .and_then(|env| env.get("LOCAL_WORKSPACE_FOLDER_BASENAME")),
4311 Some(&test_project_filename())
4312 );
4313
4314 // ${containerWorkspaceFolder}
4315 assert_eq!(
4316 variable_replaced_devcontainer
4317 .remote_env
4318 .as_ref()
4319 .and_then(|env| env.get("REMOTE_WORKSPACE_FOLDER")),
4320 Some(&format!("/workspaces/{}", test_project_filename()))
4321 );
4322
4323 // ${localWorkspaceFolder}
4324 assert_eq!(
4325 variable_replaced_devcontainer
4326 .remote_env
4327 .as_ref()
4328 .and_then(|env| env.get("LOCAL_WORKSPACE_FOLDER")),
4329 // We replace backslashes with forward slashes during variable replacement for JSON safety
4330 Some(&TEST_PROJECT_PATH.replace("\\", "/"))
4331 );
4332
4333 // ${localEnv:VARIABLE_NAME}
4334 assert_eq!(
4335 variable_replaced_devcontainer
4336 .remote_env
4337 .as_ref()
4338 .and_then(|env| env.get("LOCAL_ENV_VAR_1")),
4339 Some(&"local_env_value1".to_string())
4340 );
4341 assert_eq!(
4342 variable_replaced_devcontainer
4343 .remote_env
4344 .as_ref()
4345 .and_then(|env| env.get("LOCAL_ENV_VAR_2")),
4346 Some(&"THISVALUEHERE".to_string())
4347 );
4348 assert_eq!(
4349 variable_replaced_devcontainer
4350 .remote_env
4351 .as_ref()
4352 .and_then(|env| env.get("LOCAL_ENV_VAR_3")),
4353 Some(&"before--after".to_string())
4354 );
4355 assert_eq!(
4356 variable_replaced_devcontainer
4357 .remote_env
4358 .as_ref()
4359 .and_then(|env| env.get("LOCAL_ENV_VAR_4")),
4360 Some(&"default".to_string())
4361 );
4362 }
4363
4364 #[test]
4365 fn escape_compose_interpolation_uses_compose_dollar_escape() {
4366 assert_eq!(escape_compose_interpolation("pa$word"), "pa$$word");
4367 assert_eq!(
4368 escape_compose_interpolation("${containerEnv:PATH}"),
4369 "$${containerEnv:PATH}"
4370 );
4371 }
4372
4373 #[test]
4374 fn test_replace_environment_variables() {
4375 let replaced = DevContainerManifest::replace_environment_variables(
4376 "before ${containerEnv:FOUND} middle ${containerEnv:MISSING:default-value} after${containerEnv:MISSING2}",
4377 "containerEnv",
4378 &HashMap::from([("FOUND".to_string(), "value".to_string())]),
4379 );
4380
4381 assert_eq!(replaced, "before value middle default-value after");
4382 }
4383
4384 #[test]
4385 fn test_replace_environment_variables_supports_defaults_with_colons() {
4386 let replaced = DevContainerManifest::replace_environment_variables(
4387 "before ${containerEnv:MISSING:one:two} after",
4388 "containerEnv",
4389 &HashMap::new(),
4390 );
4391
4392 assert_eq!(replaced, "before one:two after");
4393 }
4394
4395 #[gpui::test]
4396 async fn test_nonremote_variable_replacement_with_explicit_mount(cx: &mut TestAppContext) {
4397 let given_devcontainer_contents = r#"
4398 // These are some external comments. serde_lenient should handle them
4399 {
4400 // These are some internal comments
4401 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
4402 "name": "myDevContainer-${devcontainerId}",
4403 "remoteUser": "root",
4404 "remoteEnv": {
4405 "DEVCONTAINER_ID": "${devcontainerId}",
4406 "MYVAR2": "myvarothervalue",
4407 "REMOTE_WORKSPACE_FOLDER_BASENAME": "${containerWorkspaceFolderBasename}",
4408 "LOCAL_WORKSPACE_FOLDER_BASENAME": "${localWorkspaceFolderBasename}",
4409 "REMOTE_WORKSPACE_FOLDER": "${containerWorkspaceFolder}",
4410 "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}"
4411
4412 },
4413 "workspaceMount": "source=/local/folder,target=/workspace/subfolder,type=bind,consistency=cached",
4414 "workspaceFolder": "/workspace/customfolder"
4415 }
4416 "#;
4417
4418 let (_, mut devcontainer_manifest) =
4419 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
4420 .await
4421 .unwrap();
4422
4423 devcontainer_manifest.parse_nonremote_vars().unwrap();
4424
4425 let ConfigStatus::VariableParsed(variable_replaced_devcontainer) =
4426 &devcontainer_manifest.config
4427 else {
4428 panic!("Config not parsed");
4429 };
4430
4431 // ${devcontainerId}
4432 let devcontainer_id = devcontainer_manifest.devcontainer_id();
4433 assert_eq!(
4434 variable_replaced_devcontainer.name,
4435 Some(format!("myDevContainer-{devcontainer_id}"))
4436 );
4437 assert_eq!(
4438 variable_replaced_devcontainer
4439 .remote_env
4440 .as_ref()
4441 .and_then(|env| env.get("DEVCONTAINER_ID")),
4442 Some(&devcontainer_id)
4443 );
4444
4445 // ${containerWorkspaceFolderBasename}
4446 assert_eq!(
4447 variable_replaced_devcontainer
4448 .remote_env
4449 .as_ref()
4450 .and_then(|env| env.get("REMOTE_WORKSPACE_FOLDER_BASENAME")),
4451 Some(&"customfolder".to_string())
4452 );
4453
4454 // ${localWorkspaceFolderBasename}
4455 assert_eq!(
4456 variable_replaced_devcontainer
4457 .remote_env
4458 .as_ref()
4459 .and_then(|env| env.get("LOCAL_WORKSPACE_FOLDER_BASENAME")),
4460 Some(&"project".to_string())
4461 );
4462
4463 // ${containerWorkspaceFolder}
4464 assert_eq!(
4465 variable_replaced_devcontainer
4466 .remote_env
4467 .as_ref()
4468 .and_then(|env| env.get("REMOTE_WORKSPACE_FOLDER")),
4469 Some(&"/workspace/customfolder".to_string())
4470 );
4471
4472 // ${localWorkspaceFolder}
4473 assert_eq!(
4474 variable_replaced_devcontainer
4475 .remote_env
4476 .as_ref()
4477 .and_then(|env| env.get("LOCAL_WORKSPACE_FOLDER")),
4478 // We replace backslashes with forward slashes during variable replacement for JSON safety
4479 Some(&TEST_PROJECT_PATH.replace("\\", "/"))
4480 );
4481 }
4482
4483 // updateRemoteUserUID is treated as false in Windows, so this test will fail
4484 // It is covered by test_spawns_devcontainer_with_dockerfile_and_no_update_uid
4485 #[cfg(not(target_os = "windows"))]
4486 #[gpui::test]
4487 async fn test_spawns_devcontainer_with_dockerfile_and_features(cx: &mut TestAppContext) {
4488 cx.executor().allow_parking();
4489 env_logger::try_init().ok();
4490 let given_devcontainer_contents = r#"
4491 /*---------------------------------------------------------------------------------------------
4492 * Copyright (c) Microsoft Corporation. All rights reserved.
4493 * Licensed under the MIT License. See License.txt in the project root for license information.
4494 *--------------------------------------------------------------------------------------------*/
4495 {
4496 "name": "cli-${devcontainerId}",
4497 // "image": "mcr.microsoft.com/devcontainers/typescript-node:16-bullseye",
4498 "build": {
4499 "dockerfile": "Dockerfile",
4500 "args": {
4501 "VARIANT": "18-bookworm",
4502 "FOO": "bar",
4503 },
4504 },
4505 "workspaceMount": "source=${localWorkspaceFolder},target=${containerWorkspaceFolder},type=bind,consistency=cached",
4506 "workspaceFolder": "/workspace2",
4507 "mounts": [
4508 // Keep command history across instances
4509 "source=dev-containers-cli-bashhistory,target=/home/node/commandhistory",
4510 ],
4511
4512 "runArgs": [
4513 "--cap-add=SYS_PTRACE",
4514 "--sig-proxy=true",
4515 ],
4516
4517 "forwardPorts": [
4518 8082,
4519 8083,
4520 ],
4521 "appPort": [
4522 8084,
4523 "8085:8086",
4524 ],
4525
4526 "containerEnv": {
4527 "VARIABLE_VALUE": "value",
4528 },
4529
4530 "initializeCommand": "touch IAM.md",
4531
4532 "onCreateCommand": "echo 'onCreateCommand' >> ON_CREATE_COMMAND.md",
4533
4534 "updateContentCommand": "echo 'updateContentCommand' >> UPDATE_CONTENT_COMMAND.md",
4535
4536 "postCreateCommand": {
4537 "yarn": "yarn install",
4538 "debug": "echo 'postStartCommand' >> POST_START_COMMAND.md",
4539 },
4540
4541 "postStartCommand": "echo 'postStartCommand' >> POST_START_COMMAND.md",
4542
4543 "postAttachCommand": "echo 'postAttachCommand' >> POST_ATTACH_COMMAND.md",
4544
4545 "remoteUser": "node",
4546
4547 "remoteEnv": {
4548 "PATH": "${containerEnv:PATH}:/some/other/path",
4549 "OTHER_ENV": "other_env_value"
4550 },
4551
4552 "features": {
4553 "ghcr.io/devcontainers/features/docker-in-docker:2": {
4554 "moby": false,
4555 },
4556 "ghcr.io/devcontainers/features/go:1": {},
4557 },
4558
4559 "customizations": {
4560 "vscode": {
4561 "extensions": [
4562 "dbaeumer.vscode-eslint",
4563 "GitHub.vscode-pull-request-github",
4564 ],
4565 },
4566 "zed": {
4567 "extensions": ["vue", "ruby"],
4568 },
4569 "codespaces": {
4570 "repositories": {
4571 "devcontainers/features": {
4572 "permissions": {
4573 "contents": "write",
4574 "workflows": "write",
4575 },
4576 },
4577 },
4578 },
4579 },
4580 }
4581 "#;
4582
4583 let (test_dependencies, mut devcontainer_manifest) =
4584 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
4585 .await
4586 .unwrap();
4587
4588 test_dependencies
4589 .fs
4590 .atomic_write(
4591 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"),
4592 r#"
4593# Copyright (c) Microsoft Corporation. All rights reserved.
4594# Licensed under the MIT License. See License.txt in the project root for license information.
4595ARG VARIANT="16-bullseye"
4596FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT}
4597
4598RUN mkdir -p /workspaces && chown node:node /workspaces
4599
4600ARG USERNAME=node
4601USER $USERNAME
4602
4603# Save command line history
4604RUN echo "export HISTFILE=/home/$USERNAME/commandhistory/.bash_history" >> "/home/$USERNAME/.bashrc" \
4605&& echo "export PROMPT_COMMAND='history -a'" >> "/home/$USERNAME/.bashrc" \
4606&& mkdir -p /home/$USERNAME/commandhistory \
4607&& touch /home/$USERNAME/commandhistory/.bash_history \
4608&& chown -R $USERNAME /home/$USERNAME/commandhistory
4609 "#.trim().to_string(),
4610 )
4611 .await
4612 .unwrap();
4613
4614 devcontainer_manifest.parse_nonremote_vars().unwrap();
4615
4616 let devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
4617
4618 assert_eq!(
4619 devcontainer_up.extension_ids,
4620 vec!["vue".to_string(), "ruby".to_string()]
4621 );
4622
4623 let files = test_dependencies.fs.files();
4624 let feature_dockerfile = files
4625 .iter()
4626 .find(|f| {
4627 f.file_name()
4628 .is_some_and(|s| s.display().to_string() == "Dockerfile.extended")
4629 })
4630 .expect("to be found");
4631 let feature_dockerfile = test_dependencies.fs.load(feature_dockerfile).await.unwrap();
4632 assert_eq!(
4633 &feature_dockerfile,
4634 r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
4635
4636# Copyright (c) Microsoft Corporation. All rights reserved.
4637# Licensed under the MIT License. See License.txt in the project root for license information.
4638ARG VARIANT="16-bullseye"
4639FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} AS dev_container_auto_added_stage_label
4640
4641RUN mkdir -p /workspaces && chown node:node /workspaces
4642
4643ARG USERNAME=node
4644USER $USERNAME
4645
4646# Save command line history
4647RUN echo "export HISTFILE=/home/$USERNAME/commandhistory/.bash_history" >> "/home/$USERNAME/.bashrc" \
4648&& echo "export PROMPT_COMMAND='history -a'" >> "/home/$USERNAME/.bashrc" \
4649&& mkdir -p /home/$USERNAME/commandhistory \
4650&& touch /home/$USERNAME/commandhistory/.bash_history \
4651&& chown -R $USERNAME /home/$USERNAME/commandhistory
4652
4653FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize
4654USER root
4655COPY --from=dev_containers_feature_content_source ./devcontainer-features.builtin.env /tmp/build-features/
4656RUN chmod -R 0755 /tmp/build-features/
4657
4658FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage
4659
4660USER root
4661
4662RUN mkdir -p /tmp/dev-container-features
4663COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ /tmp/dev-container-features
4664
4665RUN \
4666echo "_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 && \
4667echo "_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
4668
4669
4670RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./docker-in-docker_0,target=/tmp/build-features-src/docker-in-docker_0 \
4671cp -ar /tmp/build-features-src/docker-in-docker_0 /tmp/dev-container-features \
4672&& chmod -R 0755 /tmp/dev-container-features/docker-in-docker_0 \
4673&& cd /tmp/dev-container-features/docker-in-docker_0 \
4674&& chmod +x ./devcontainer-features-install.sh \
4675&& ./devcontainer-features-install.sh \
4676&& rm -rf /tmp/dev-container-features/docker-in-docker_0
4677
4678RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./go_1,target=/tmp/build-features-src/go_1 \
4679cp -ar /tmp/build-features-src/go_1 /tmp/dev-container-features \
4680&& chmod -R 0755 /tmp/dev-container-features/go_1 \
4681&& cd /tmp/dev-container-features/go_1 \
4682&& chmod +x ./devcontainer-features-install.sh \
4683&& ./devcontainer-features-install.sh \
4684&& rm -rf /tmp/dev-container-features/go_1
4685
4686
4687ARG _DEV_CONTAINERS_IMAGE_USER=root
4688USER $_DEV_CONTAINERS_IMAGE_USER
4689"#
4690 );
4691
4692 let uid_dockerfile = files
4693 .iter()
4694 .find(|f| {
4695 f.file_name()
4696 .is_some_and(|s| s.display().to_string() == "updateUID.Dockerfile")
4697 })
4698 .expect("to be found");
4699 let uid_dockerfile = test_dependencies.fs.load(uid_dockerfile).await.unwrap();
4700
4701 assert_eq!(
4702 &uid_dockerfile,
4703 r#"ARG BASE_IMAGE
4704FROM $BASE_IMAGE
4705
4706USER root
4707
4708ARG REMOTE_USER
4709ARG NEW_UID
4710ARG NEW_GID
4711SHELL ["/bin/sh", "-c"]
4712RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/OLD_UID=\1;OLD_GID=\2;HOME_FOLDER=\3/p" /etc/passwd); \
4713 eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_UID}:.*/EXISTING_USER=\1/p" /etc/passwd); \
4714 eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_GID}:.*/EXISTING_GROUP=\1/p" /etc/group); \
4715 if [ -z "$OLD_UID" ]; then \
4716 echo "Remote user not found in /etc/passwd ($REMOTE_USER)."; \
4717 elif [ "$OLD_UID" = "$NEW_UID" -a "$OLD_GID" = "$NEW_GID" ]; then \
4718 echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \
4719 elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \
4720 echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \
4721 else \
4722 if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \
4723 FREE_GID=65532; \
4724 while grep -q ":[^:]*:${FREE_GID}:" /etc/group; do FREE_GID=$((FREE_GID - 1)); done; \
4725 echo "Reassigning group $EXISTING_GROUP from GID $NEW_GID to $FREE_GID."; \
4726 sed -i -e "s/\(${EXISTING_GROUP}:[^:]*:\)${NEW_GID}:/\1${FREE_GID}:/" /etc/group; \
4727 fi; \
4728 echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \
4729 sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \
4730 if [ "$OLD_GID" != "$NEW_GID" ]; then \
4731 sed -i -e "s/\([^:]*:[^:]*:\)${OLD_GID}:/\1${NEW_GID}:/" /etc/group; \
4732 fi; \
4733 chown -R $NEW_UID:$NEW_GID $HOME_FOLDER; \
4734 fi;
4735
4736ARG IMAGE_USER
4737USER $IMAGE_USER
4738
4739# Ensure that /etc/profile does not clobber the existing path
4740RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
4741
4742ENV DOCKER_BUILDKIT=1
4743
4744ENV GOPATH=/go
4745ENV GOROOT=/usr/local/go
4746ENV PATH=/usr/local/go/bin:/go/bin:${PATH}
4747ENV VARIABLE_VALUE=value
4748"#
4749 );
4750
4751 let golang_install_wrapper = files
4752 .iter()
4753 .find(|f| {
4754 f.file_name()
4755 .is_some_and(|s| s.display().to_string() == "devcontainer-features-install.sh")
4756 && f.to_str().is_some_and(|s| s.contains("/go_"))
4757 })
4758 .expect("to be found");
4759 let golang_install_wrapper = test_dependencies
4760 .fs
4761 .load(golang_install_wrapper)
4762 .await
4763 .unwrap();
4764 assert_eq!(
4765 &golang_install_wrapper,
4766 r#"#!/bin/sh
4767set -e
4768
4769on_exit () {
4770 [ $? -eq 0 ] && exit
4771 echo 'ERROR: Feature "go" (ghcr.io/devcontainers/features/go:1) failed to install!'
4772}
4773
4774trap on_exit EXIT
4775
4776echo ===========================================================================
4777echo 'Feature : go'
4778echo 'Id : ghcr.io/devcontainers/features/go:1'
4779echo 'Options :'
4780echo ' GOLANGCILINTVERSION=latest
4781 VERSION=latest'
4782echo ===========================================================================
4783
4784set -a
4785. ../devcontainer-features.builtin.env
4786. ./devcontainer-features.env
4787set +a
4788
4789chmod +x ./install.sh
4790./install.sh
4791"#
4792 );
4793
4794 let docker_commands = test_dependencies
4795 .command_runner
4796 .commands_by_program("docker");
4797
4798 let docker_run_command = docker_commands
4799 .iter()
4800 .find(|c| c.args.get(0).is_some_and(|a| a == "run"))
4801 .expect("found");
4802
4803 let args = &docker_run_command.args;
4804
4805 assert!(args.contains(&"--cap-add".to_string()));
4806 assert!(args.contains(&"SYS_PTRACE".to_string()));
4807 assert!(args.contains(&"--security-opt".to_string()));
4808 assert!(args.contains(&"seccomp=unconfined".to_string()));
4809
4810 let metadata_arg = args
4811 .iter()
4812 .find(|a| a.starts_with("devcontainer.metadata="))
4813 .unwrap();
4814 let metadata_json = &metadata_arg["devcontainer.metadata=".len()..];
4815 let metadata: Vec<serde_json_lenient::Value> =
4816 serde_json_lenient::from_str(metadata_json).unwrap();
4817 assert_eq!(metadata.len(), 4);
4818 assert_eq!(metadata[0]["remoteUser"], "node");
4819 assert_eq!(
4820 metadata[1]["id"],
4821 "ghcr.io/devcontainers/features/docker-in-docker:2"
4822 );
4823 assert_eq!(metadata[1]["privileged"], true);
4824 assert!(metadata[1].get("containerEnv").is_none());
4825 assert_eq!(metadata[2]["id"], "ghcr.io/devcontainers/features/go:1");
4826 assert!(metadata[2].get("containerEnv").is_none());
4827 assert!(
4828 metadata[2]["capAdd"]
4829 .as_array()
4830 .unwrap()
4831 .contains(&serde_json_lenient::Value::String("SYS_PTRACE".to_string()))
4832 );
4833 assert_eq!(metadata[3]["remoteUser"], "node");
4834 assert!(metadata[3].get("onCreateCommand").is_some());
4835
4836 let env_args: Vec<&String> = args
4837 .iter()
4838 .enumerate()
4839 .filter(|(_, a)| *a == "-e")
4840 .filter_map(|(i, _)| args.get(i + 1))
4841 .collect();
4842 assert!(env_args.contains(&&"VARIABLE_VALUE=value".to_string()));
4843 assert!(!env_args.contains(&&"DOCKER_BUILDKIT=1".to_string()));
4844 assert!(!env_args.contains(&&"GOPATH=/go".to_string()));
4845 assert!(!env_args.contains(&&"GOROOT=/usr/local/go".to_string()));
4846
4847 let image_arg = args.iter().find(|a| a.contains("-features")).unwrap();
4848 assert!(image_arg.ends_with("-features"));
4849
4850 let docker_exec_commands = test_dependencies
4851 .docker
4852 .exec_commands_recorded
4853 .lock()
4854 .unwrap();
4855
4856 assert!(docker_exec_commands.iter().all(|exec| {
4857 exec.env
4858 == HashMap::from([
4859 ("OTHER_ENV".to_string(), "other_env_value".to_string()),
4860 (
4861 "PATH".to_string(),
4862 "/initial/path:/some/other/path".to_string(),
4863 ),
4864 ])
4865 }))
4866 }
4867
4868 // updateRemoteUserUID is treated as false in Windows, so this test will fail
4869 // It is covered by test_spawns_devcontainer_with_docker_compose_and_no_update_uid
4870 #[cfg(not(target_os = "windows"))]
4871 #[gpui::test]
4872 async fn test_spawns_devcontainer_with_docker_compose(cx: &mut TestAppContext) {
4873 cx.executor().allow_parking();
4874 env_logger::try_init().ok();
4875 let given_devcontainer_contents = r#"
4876 // For format details, see https://aka.ms/devcontainer.json. For config options, see the
4877 // README at: https://github.com/devcontainers/templates/tree/main/src/rust-postgres
4878 {
4879 "features": {
4880 "ghcr.io/devcontainers/features/aws-cli:1": {},
4881 "ghcr.io/devcontainers/features/docker-in-docker:2": {},
4882 },
4883 "name": "Rust and PostgreSQL",
4884 "dockerComposeFile": "docker-compose.yml",
4885 "service": "app",
4886 "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
4887
4888 // Features to add to the dev container. More info: https://containers.dev/features.
4889 // "features": {},
4890
4891 // Use 'forwardPorts' to make a list of ports inside the container available locally.
4892 "forwardPorts": [
4893 8083,
4894 "db:5432",
4895 "db:1234",
4896 ],
4897
4898 // Use 'postCreateCommand' to run commands after the container is created.
4899 // "postCreateCommand": "rustc --version",
4900
4901 // Configure tool-specific properties.
4902 // "customizations": {},
4903
4904 // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
4905 // "remoteUser": "root"
4906 }
4907 "#;
4908 let (test_dependencies, mut devcontainer_manifest) =
4909 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
4910 .await
4911 .unwrap();
4912
4913 test_dependencies
4914 .fs
4915 .atomic_write(
4916 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"),
4917 r#"
4918version: '3.8'
4919
4920volumes:
4921 postgres-data:
4922
4923services:
4924 app:
4925 build:
4926 context: .
4927 dockerfile: Dockerfile
4928 env_file:
4929 # Ensure that the variables in .env match the same variables in devcontainer.json
4930 - .env
4931
4932 volumes:
4933 - ../..:/workspaces:cached
4934
4935 # Overrides default command so things don't shut down after the process ends.
4936 command: sleep infinity
4937
4938 # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
4939 network_mode: service:db
4940
4941 # Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
4942 # (Adding the "ports" property to this file will not forward from a Codespace.)
4943
4944 db:
4945 image: postgres:14.1
4946 restart: unless-stopped
4947 volumes:
4948 - postgres-data:/var/lib/postgresql/data
4949 env_file:
4950 # Ensure that the variables in .env match the same variables in devcontainer.json
4951 - .env
4952
4953 # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
4954 # (Adding the "ports" property to this file will not forward from a Codespace.)
4955 "#.trim().to_string(),
4956 )
4957 .await
4958 .unwrap();
4959
4960 test_dependencies.fs.atomic_write(
4961 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"),
4962 r#"
4963FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm
4964
4965# Include lld linker to improve build times either by using environment variable
4966# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml).
4967RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
4968 && apt-get -y install clang lld \
4969 && apt-get autoremove -y && apt-get clean -y
4970 "#.trim().to_string()).await.unwrap();
4971
4972 devcontainer_manifest.parse_nonremote_vars().unwrap();
4973
4974 let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
4975
4976 let docker_commands = test_dependencies
4977 .command_runner
4978 .commands_by_program("docker");
4979 let compose_up = docker_commands
4980 .iter()
4981 .find(|c| {
4982 c.args.first().map(String::as_str) == Some("compose")
4983 && c.args.iter().any(|a| a == "up")
4984 })
4985 .expect("docker compose up command recorded");
4986 let project_name_idx = compose_up
4987 .args
4988 .iter()
4989 .position(|a| a == "--project-name")
4990 .expect("compose command has --project-name flag");
4991 assert_eq!(
4992 compose_up.args[project_name_idx + 1],
4993 "project_devcontainer",
4994 "compose project name should match @devcontainers/cli derivation \
4995 (${{folderBasename}}_devcontainer), ignoring devcontainer.json `name`"
4996 );
4997
4998 let files = test_dependencies.fs.files();
4999 let feature_dockerfile = files
5000 .iter()
5001 .find(|f| {
5002 f.file_name()
5003 .is_some_and(|s| s.display().to_string() == "Dockerfile.extended")
5004 })
5005 .expect("to be found");
5006 let feature_dockerfile = test_dependencies.fs.load(feature_dockerfile).await.unwrap();
5007 assert_eq!(
5008 &feature_dockerfile,
5009 r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
5010
5011FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm AS dev_container_auto_added_stage_label
5012
5013# Include lld linker to improve build times either by using environment variable
5014# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml).
5015RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
5016 && apt-get -y install clang lld \
5017 && apt-get autoremove -y && apt-get clean -y
5018
5019FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize
5020USER root
5021COPY --from=dev_containers_feature_content_source ./devcontainer-features.builtin.env /tmp/build-features/
5022RUN chmod -R 0755 /tmp/build-features/
5023
5024FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage
5025
5026USER root
5027
5028RUN mkdir -p /tmp/dev-container-features
5029COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ /tmp/dev-container-features
5030
5031RUN \
5032echo "_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 && \
5033echo "_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
5034
5035
5036RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./aws-cli_0,target=/tmp/build-features-src/aws-cli_0 \
5037cp -ar /tmp/build-features-src/aws-cli_0 /tmp/dev-container-features \
5038&& chmod -R 0755 /tmp/dev-container-features/aws-cli_0 \
5039&& cd /tmp/dev-container-features/aws-cli_0 \
5040&& chmod +x ./devcontainer-features-install.sh \
5041&& ./devcontainer-features-install.sh \
5042&& rm -rf /tmp/dev-container-features/aws-cli_0
5043
5044RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./docker-in-docker_1,target=/tmp/build-features-src/docker-in-docker_1 \
5045cp -ar /tmp/build-features-src/docker-in-docker_1 /tmp/dev-container-features \
5046&& chmod -R 0755 /tmp/dev-container-features/docker-in-docker_1 \
5047&& cd /tmp/dev-container-features/docker-in-docker_1 \
5048&& chmod +x ./devcontainer-features-install.sh \
5049&& ./devcontainer-features-install.sh \
5050&& rm -rf /tmp/dev-container-features/docker-in-docker_1
5051
5052
5053ARG _DEV_CONTAINERS_IMAGE_USER=root
5054USER $_DEV_CONTAINERS_IMAGE_USER
5055"#
5056 );
5057
5058 let uid_dockerfile = files
5059 .iter()
5060 .find(|f| {
5061 f.file_name()
5062 .is_some_and(|s| s.display().to_string() == "updateUID.Dockerfile")
5063 })
5064 .expect("to be found");
5065 let uid_dockerfile = test_dependencies.fs.load(uid_dockerfile).await.unwrap();
5066
5067 assert_eq!(
5068 &uid_dockerfile,
5069 r#"ARG BASE_IMAGE
5070FROM $BASE_IMAGE
5071
5072USER root
5073
5074ARG REMOTE_USER
5075ARG NEW_UID
5076ARG NEW_GID
5077SHELL ["/bin/sh", "-c"]
5078RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/OLD_UID=\1;OLD_GID=\2;HOME_FOLDER=\3/p" /etc/passwd); \
5079 eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_UID}:.*/EXISTING_USER=\1/p" /etc/passwd); \
5080 eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_GID}:.*/EXISTING_GROUP=\1/p" /etc/group); \
5081 if [ -z "$OLD_UID" ]; then \
5082 echo "Remote user not found in /etc/passwd ($REMOTE_USER)."; \
5083 elif [ "$OLD_UID" = "$NEW_UID" -a "$OLD_GID" = "$NEW_GID" ]; then \
5084 echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \
5085 elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \
5086 echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \
5087 else \
5088 if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \
5089 FREE_GID=65532; \
5090 while grep -q ":[^:]*:${FREE_GID}:" /etc/group; do FREE_GID=$((FREE_GID - 1)); done; \
5091 echo "Reassigning group $EXISTING_GROUP from GID $NEW_GID to $FREE_GID."; \
5092 sed -i -e "s/\(${EXISTING_GROUP}:[^:]*:\)${NEW_GID}:/\1${FREE_GID}:/" /etc/group; \
5093 fi; \
5094 echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \
5095 sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \
5096 if [ "$OLD_GID" != "$NEW_GID" ]; then \
5097 sed -i -e "s/\([^:]*:[^:]*:\)${OLD_GID}:/\1${NEW_GID}:/" /etc/group; \
5098 fi; \
5099 chown -R $NEW_UID:$NEW_GID $HOME_FOLDER; \
5100 fi;
5101
5102ARG IMAGE_USER
5103USER $IMAGE_USER
5104
5105# Ensure that /etc/profile does not clobber the existing path
5106RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
5107
5108
5109ENV DOCKER_BUILDKIT=1
5110"#
5111 );
5112
5113 let build_override = files
5114 .iter()
5115 .find(|f| {
5116 f.file_name()
5117 .is_some_and(|s| s.display().to_string() == "docker_compose_build.json")
5118 })
5119 .expect("to be found");
5120 let build_override = test_dependencies.fs.load(build_override).await.unwrap();
5121 let build_config: DockerComposeConfig =
5122 serde_json_lenient::from_str(&build_override).unwrap();
5123 let build_context = build_config
5124 .services
5125 .get("app")
5126 .and_then(|s| s.build.as_ref())
5127 .and_then(|b| b.context.clone())
5128 .expect("build override should have a context");
5129 assert_eq!(
5130 build_context, ".",
5131 "build override should preserve the original build context from docker-compose.yml"
5132 );
5133
5134 let runtime_override = files
5135 .iter()
5136 .find(|f| {
5137 f.file_name()
5138 .is_some_and(|s| s.display().to_string() == "docker_compose_runtime.json")
5139 })
5140 .expect("to be found");
5141 let runtime_override = test_dependencies.fs.load(runtime_override).await.unwrap();
5142
5143 let runtime_config: DockerComposeConfig =
5144 serde_json_lenient::from_str(&runtime_override).unwrap();
5145
5146 let app_service = runtime_config.services.get("app").expect("app service");
5147 assert_eq!(app_service.cap_add, Some(vec!["SYS_PTRACE".to_string()]));
5148 assert_eq!(
5149 app_service.security_opt,
5150 Some(vec!["seccomp=unconfined".to_string()])
5151 );
5152 assert_eq!(app_service.privileged, Some(true));
5153 assert!(app_service.entrypoint.is_some());
5154
5155 let labels = app_service.labels.as_ref().unwrap();
5156 assert_eq!(
5157 labels.get("devcontainer.local_folder").unwrap(),
5158 "/path/to/local/project"
5159 );
5160 assert_eq!(
5161 labels.get("devcontainer.config_file").unwrap(),
5162 "/path/to/local/project/.devcontainer/devcontainer.json"
5163 );
5164
5165 let metadata_json = labels.get("devcontainer.metadata").unwrap();
5166 let metadata: Vec<serde_json_lenient::Value> =
5167 serde_json_lenient::from_str(metadata_json).unwrap();
5168 assert!(metadata.len() >= 2);
5169 assert_eq!(metadata[0]["remoteUser"], "vscode");
5170 let has_dind_feature = metadata.iter().any(|e| {
5171 e.get("id").and_then(|v| v.as_str())
5172 == Some("ghcr.io/devcontainers/features/docker-in-docker:2")
5173 });
5174 assert!(
5175 has_dind_feature,
5176 "metadata should include docker-in-docker feature entry"
5177 );
5178 let has_forward_ports = metadata.iter().any(|e| e.get("forwardPorts").is_some());
5179 assert!(
5180 has_forward_ports,
5181 "metadata should include devcontainer.json config entry with forwardPorts"
5182 );
5183
5184 assert_eq!(app_service.volumes.len(), 1);
5185 assert_eq!(app_service.volumes[0].target, "/var/lib/docker");
5186
5187 let db_service = runtime_config.services.get("db").expect("db service");
5188 assert_eq!(db_service.ports.len(), 3);
5189
5190 assert!(
5191 runtime_config
5192 .volumes
5193 .contains_key("dind-var-lib-docker-42dad4b4ca7b8ced")
5194 )
5195 }
5196
5197 #[gpui::test]
5198 async fn test_compose_build_includes_primary_service_when_run_services_excludes_it(
5199 cx: &mut TestAppContext,
5200 ) {
5201 cx.executor().allow_parking();
5202 env_logger::try_init().ok();
5203 // `app` (the primary `service`) is intentionally left out of `runServices`;
5204 // the spec still implies it, so its features image must still be built.
5205 let given_devcontainer_contents = r#"
5206 {
5207 "features": {
5208 "ghcr.io/devcontainers/features/aws-cli:1": {},
5209 "ghcr.io/devcontainers/features/docker-in-docker:2": {},
5210 },
5211 "name": "Rust and PostgreSQL",
5212 "dockerComposeFile": "docker-compose.yml",
5213 "service": "app",
5214 "runServices": ["db"],
5215 "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}"
5216 }
5217 "#;
5218 let (test_dependencies, mut devcontainer_manifest) =
5219 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
5220 .await
5221 .unwrap();
5222
5223 test_dependencies
5224 .fs
5225 .atomic_write(
5226 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"),
5227 r#"
5228volumes:
5229 postgres-data:
5230
5231services:
5232 app:
5233 build:
5234 context: .
5235 dockerfile: Dockerfile
5236 volumes:
5237 - ../..:/workspaces:cached
5238 command: sleep infinity
5239 network_mode: service:db
5240
5241 db:
5242 image: postgres:14.1
5243 restart: unless-stopped
5244 volumes:
5245 - postgres-data:/var/lib/postgresql/data
5246 "#
5247 .trim()
5248 .to_string(),
5249 )
5250 .await
5251 .unwrap();
5252
5253 test_dependencies
5254 .fs
5255 .atomic_write(
5256 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"),
5257 r#"
5258FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm
5259RUN apt-get update
5260 "#
5261 .trim()
5262 .to_string(),
5263 )
5264 .await
5265 .unwrap();
5266
5267 devcontainer_manifest.parse_nonremote_vars().unwrap();
5268
5269 let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
5270
5271 let recorded = test_dependencies.docker.recorded_compose_build_services();
5272 let build_services = recorded
5273 .iter()
5274 .find_map(|services| services.clone())
5275 .expect("docker_compose_build should have been invoked with a service list");
5276
5277 assert!(
5278 build_services.iter().any(|s| s == "app"),
5279 "compose build must include the primary service `app` even when \
5280 runServices omits it; got {build_services:?}"
5281 );
5282 assert_eq!(
5283 build_services.iter().filter(|s| *s == "app").count(),
5284 1,
5285 "primary service `app` must appear exactly once (de-duplicated); \
5286 got {build_services:?}"
5287 );
5288
5289 // The implied-primary rule applies to `up -d` too, not just the build.
5290 let docker_commands = test_dependencies
5291 .command_runner
5292 .commands_by_program("docker");
5293 let compose_up = docker_commands
5294 .iter()
5295 .find(|c| {
5296 c.args.first().map(String::as_str) == Some("compose")
5297 && c.args.iter().any(|a| a == "up")
5298 })
5299 .expect("docker compose up command recorded");
5300 let up_index = compose_up
5301 .args
5302 .iter()
5303 .position(|a| a == "up")
5304 .expect("up token present");
5305 let up_service_args = &compose_up.args[up_index + 2..];
5306 assert!(
5307 up_service_args.iter().any(|s| s == "app"),
5308 "compose up must start the primary service `app` even when \
5309 runServices omits it; got {:?}",
5310 compose_up.args
5311 );
5312 assert_eq!(
5313 up_service_args.iter().filter(|s| *s == "app").count(),
5314 1,
5315 "primary service `app` must appear exactly once in the up service \
5316 args (de-duplicated); got {:?}",
5317 compose_up.args
5318 );
5319 }
5320
5321 #[test]
5322 fn derive_project_name_env_wins_over_everything() {
5323 // CLI precedence rule 1: `COMPOSE_PROJECT_NAME` env var short-circuits
5324 // every later source (.env, compose name:, basename fallback).
5325 use crate::devcontainer_manifest::derive_project_name;
5326
5327 let env = HashMap::from([("COMPOSE_PROJECT_NAME".to_string(), "from_env".to_string())]);
5328 let got = derive_project_name(
5329 &env,
5330 Some("COMPOSE_PROJECT_NAME=from_dotenv\n"),
5331 Some("from_compose_name"),
5332 true,
5333 Some(Path::new(
5334 "/path/to/local/project/.devcontainer/docker-compose.yml",
5335 )),
5336 Path::new("/path/to/local/project"),
5337 "project",
5338 );
5339 assert_eq!(got, "from_env");
5340 }
5341
5342 #[test]
5343 fn derive_project_name_dotenv_wins_over_compose_and_fallback() {
5344 // CLI precedence rule 2: when no env var is set, the workspace .env's
5345 // `COMPOSE_PROJECT_NAME=` line wins over the compose config's `name:`
5346 // field and the basename fallback.
5347 use crate::devcontainer_manifest::derive_project_name;
5348
5349 let got = derive_project_name(
5350 &HashMap::new(),
5351 Some("# comment\nCOMPOSE_PROJECT_NAME=from_dotenv\n"),
5352 Some("from_compose_name"),
5353 true,
5354 Some(Path::new(
5355 "/path/to/local/project/.devcontainer/docker-compose.yml",
5356 )),
5357 Path::new("/path/to/local/project"),
5358 "project",
5359 );
5360 assert_eq!(got, "from_dotenv");
5361 }
5362
5363 #[test]
5364 fn derive_project_name_compose_name_wins_over_fallback() {
5365 // CLI precedence rule 3: when neither env nor .env provide a name,
5366 // the merged compose config's top-level `name:` field takes precedence
5367 // over the basename fallback. Also covers sanitization (spaces
5368 // stripped, uppercase lowercased).
5369 use crate::devcontainer_manifest::derive_project_name;
5370
5371 let got = derive_project_name(
5372 &HashMap::new(),
5373 None,
5374 Some("My Compose Project"),
5375 true,
5376 Some(Path::new(
5377 "/path/to/local/project/.devcontainer/docker-compose.yml",
5378 )),
5379 Path::new("/path/to/local/project"),
5380 "project",
5381 );
5382 assert_eq!(got, "mycomposeproject");
5383 }
5384
5385 #[test]
5386 fn derive_project_name_skips_compose_name_when_not_explicitly_declared() {
5387 // CLI precedence rule 3 edge case: `docker compose config` injects a
5388 // default `name: devcontainer` into the merged output whenever no
5389 // compose fragment declared one. `@devcontainers/cli` ignores that
5390 // default by tracking per-fragment whether `name:` was declared and
5391 // skipping rule 3 if none was. The caller conveys that signal via
5392 // `compose_name_explicitly_declared`; when it's `false`, even a
5393 // non-empty `compose_config_name` must be skipped so rule 4 applies.
5394 use crate::devcontainer_manifest::derive_project_name;
5395
5396 let got = derive_project_name(
5397 &HashMap::new(),
5398 None,
5399 Some("devcontainer"),
5400 false,
5401 Some(Path::new(
5402 "/path/to/myworkspace/.devcontainer/docker-compose.yml",
5403 )),
5404 Path::new("/path/to/myworkspace"),
5405 "myworkspace",
5406 );
5407 assert_eq!(got, "myworkspace_devcontainer");
5408 }
5409
5410 #[test]
5411 fn derive_project_name_omits_suffix_when_compose_file_outside_devcontainer_dir() {
5412 // CLI precedence rule 4: when falling back to the first compose file's
5413 // directory basename, the `_devcontainer` suffix is only appended when
5414 // that directory IS `<config>/.devcontainer`. A compose file at the
5415 // workspace root (as `"dockerComposeFile": "../docker-compose.yml"`
5416 // produces) must derive to the plain dir basename, not
5417 // `project_devcontainer` — otherwise Zed diverges from the CLI.
5418 use crate::devcontainer_manifest::derive_project_name;
5419
5420 let got = derive_project_name(
5421 &HashMap::new(),
5422 None,
5423 None,
5424 false,
5425 Some(Path::new("/path/to/local/project/docker-compose.yml")),
5426 Path::new("/path/to/local/project"),
5427 "project",
5428 );
5429 assert_eq!(got, "project");
5430 }
5431
5432 #[test]
5433 fn derive_project_name_handles_resolved_paths_from_docker_compose_manifest() {
5434 // `docker_compose_manifest()` normalizes compose file paths upfront
5435 // (resolving `..` components from raw `dockerComposeFile` entries like
5436 // `"subdir/../docker-compose.yml"`) before populating
5437 // `DockerComposeResources.files`. This test pins the resulting
5438 // rule-4/rule-5 behavior on those normalized paths: a file
5439 // semantically under `<workspace>/.devcontainer` takes rule 4, and
5440 // one that resolves outside it takes rule 5.
5441 use crate::devcontainer_manifest::derive_project_name;
5442
5443 // Normalized equivalent of `.devcontainer/subdir/../docker-compose.yml`:
5444 // rule 4 applies → `${ws}_devcontainer`.
5445 let got_under = derive_project_name(
5446 &HashMap::new(),
5447 None,
5448 None,
5449 false,
5450 Some(Path::new(
5451 "/path/to/local/project/.devcontainer/docker-compose.yml",
5452 )),
5453 Path::new("/path/to/local/project"),
5454 "project",
5455 );
5456 assert_eq!(got_under, "project_devcontainer");
5457
5458 // Normalized equivalent of `.devcontainer/../docker-compose.yml`:
5459 // the file sits at the workspace root, so rule 5 applies — plain
5460 // basename of the parent dir, no suffix.
5461 let got_escaped = derive_project_name(
5462 &HashMap::new(),
5463 None,
5464 None,
5465 false,
5466 Some(Path::new("/path/to/local/project/docker-compose.yml")),
5467 Path::new("/path/to/local/project"),
5468 "project",
5469 );
5470 assert_eq!(got_escaped, "project");
5471 }
5472
5473 #[test]
5474 fn compose_fragment_declares_name_detects_top_level_name_key() {
5475 // Block-style top-level key — declared.
5476 use crate::devcontainer_manifest::compose_fragment_declares_name;
5477
5478 assert!(compose_fragment_declares_name(
5479 "name: my-project\nservices:\n app:\n image: foo\n"
5480 ));
5481 // Indented `name:` belongs to a nested mapping (here a service) and
5482 // must NOT count as a top-level declaration.
5483 assert!(!compose_fragment_declares_name(
5484 "services:\n app:\n name: inner\n image: foo\n"
5485 ));
5486 // Comment lines are ignored.
5487 assert!(!compose_fragment_declares_name(
5488 "# name: commented-out\nservices: {}\n"
5489 ));
5490 // Empty fragment — no declaration.
5491 assert!(!compose_fragment_declares_name(""));
5492 // Quoted key — still a top-level declaration. A line scanner that
5493 // looks for bare `name:` at column 0 would miss this.
5494 assert!(compose_fragment_declares_name(
5495 "\"name\": my-project\nservices: {}\n"
5496 ));
5497 // Flow-style root mapping — also a top-level declaration. Again a
5498 // line scanner keyed on block-style layout would miss it.
5499 assert!(compose_fragment_declares_name(
5500 "{name: my-project, services: {app: {image: foo}}}\n"
5501 ));
5502 // Unparsable fragment falls through to "not declared" (matches the
5503 // CLI's behavior on parse failure).
5504 assert!(!compose_fragment_declares_name(": : :\n- - -\n"));
5505 }
5506
5507 #[test]
5508 fn is_missing_file_error_only_accepts_notfound_and_isadirectory() {
5509 // Mirrors the CLI's narrow `ENOENT`/`EISDIR` swallow in
5510 // `getProjectName`'s `.env` read. Any other `io::Error` — permission
5511 // denied, I/O failure, `ENOTDIR`, etc. — must not be classified as
5512 // "missing" so callers surface the problem instead of silently
5513 // falling back to a non-canonical project name. Non-`io::Error`
5514 // anyhow errors must also not be classified as missing.
5515 use crate::devcontainer_manifest::is_missing_file_error;
5516
5517 let notfound = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::NotFound));
5518 assert!(is_missing_file_error(¬found));
5519
5520 // EISDIR — `.env` exists as a directory; CLI swallows, so must we.
5521 let is_a_dir = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::IsADirectory));
5522 assert!(is_missing_file_error(&is_a_dir));
5523
5524 // ENOTDIR — a path component isn't a directory; CLI does NOT
5525 // swallow this (its catch is narrow to ENOENT/EISDIR), so we must
5526 // propagate it as a real failure.
5527 let not_a_dir = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::NotADirectory));
5528 assert!(!is_missing_file_error(¬_a_dir));
5529
5530 let permission_denied =
5531 anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
5532 assert!(!is_missing_file_error(&permission_denied));
5533
5534 let other_io = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::Other));
5535 assert!(!is_missing_file_error(&other_io));
5536
5537 let non_io: anyhow::Error = anyhow::anyhow!("something else");
5538 assert!(!is_missing_file_error(&non_io));
5539 }
5540
5541 #[test]
5542 fn sanitize_compose_project_name_matches_cli_rules() {
5543 use crate::devcontainer_manifest::sanitize_compose_project_name;
5544
5545 // Plain lowercase alnum passes through.
5546 assert_eq!(
5547 sanitize_compose_project_name("project_devcontainer"),
5548 "project_devcontainer"
5549 );
5550 // Hyphens survive (unlike safe_id_lower which would replace them with _).
5551 assert_eq!(
5552 sanitize_compose_project_name("devcontainer-compose-test_devcontainer"),
5553 "devcontainer-compose-test_devcontainer"
5554 );
5555 // Uppercase letters are lowercased.
5556 assert_eq!(
5557 sanitize_compose_project_name("Makermint-Studio_devcontainer"),
5558 "makermint-studio_devcontainer"
5559 );
5560 // Characters outside [-_a-z0-9] are stripped.
5561 assert_eq!(
5562 sanitize_compose_project_name("Rust & PostgreSQL_devcontainer"),
5563 "rustpostgresql_devcontainer"
5564 );
5565 }
5566
5567 #[test]
5568 fn test_resolve_compose_dockerfile() {
5569 let compose = Path::new("/project/.devcontainer/docker-compose.yml");
5570
5571 // Bug case (#53473): context ".." with relative dockerfile
5572 assert_eq!(
5573 resolve_compose_dockerfile(compose, Some(".."), ".devcontainer/Dockerfile"),
5574 Some(PathBuf::from("/project/.devcontainer/Dockerfile")),
5575 );
5576
5577 // Compose path containing ".." (as docker_compose_manifest() produces)
5578 assert_eq!(
5579 resolve_compose_dockerfile(
5580 Path::new("/project/.devcontainer/../docker-compose.yml"),
5581 Some("."),
5582 "docker/Dockerfile",
5583 ),
5584 Some(PathBuf::from("/project/docker/Dockerfile")),
5585 );
5586
5587 // Absolute dockerfile returned as-is
5588 assert_eq!(
5589 resolve_compose_dockerfile(compose, Some("."), "/absolute/Dockerfile"),
5590 Some(PathBuf::from("/absolute/Dockerfile")),
5591 );
5592
5593 // Absolute context used directly
5594 assert_eq!(
5595 resolve_compose_dockerfile(compose, Some("/abs/context"), "Dockerfile"),
5596 Some(PathBuf::from("/abs/context/Dockerfile")),
5597 );
5598
5599 // No context defaults to compose file's directory
5600 assert_eq!(
5601 resolve_compose_dockerfile(compose, None, "Dockerfile"),
5602 Some(PathBuf::from("/project/.devcontainer/Dockerfile")),
5603 );
5604 }
5605
5606 #[gpui::test]
5607 async fn test_dockerfile_location_with_compose_context_parent(cx: &mut TestAppContext) {
5608 cx.executor().allow_parking();
5609 env_logger::try_init().ok();
5610
5611 let given_devcontainer_contents = r#"
5612 {
5613 "name": "Test",
5614 "dockerComposeFile": "docker-compose-context-parent.yml",
5615 "service": "app",
5616 "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}"
5617 }
5618 "#;
5619 let (_, mut devcontainer_manifest) =
5620 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
5621 .await
5622 .unwrap();
5623
5624 devcontainer_manifest.parse_nonremote_vars().unwrap();
5625
5626 let expected = PathBuf::from(TEST_PROJECT_PATH)
5627 .join(".devcontainer")
5628 .join("Dockerfile");
5629 assert_eq!(
5630 devcontainer_manifest.dockerfile_location().await,
5631 Some(expected)
5632 );
5633 }
5634
5635 #[gpui::test]
5636 async fn test_spawns_devcontainer_with_docker_compose_and_no_update_uid(
5637 cx: &mut TestAppContext,
5638 ) {
5639 cx.executor().allow_parking();
5640 env_logger::try_init().ok();
5641 let given_devcontainer_contents = r#"
5642 // For format details, see https://aka.ms/devcontainer.json. For config options, see the
5643 // README at: https://github.com/devcontainers/templates/tree/main/src/rust-postgres
5644 {
5645 "features": {
5646 "ghcr.io/devcontainers/features/aws-cli:1": {},
5647 "ghcr.io/devcontainers/features/docker-in-docker:2": {},
5648 },
5649 "name": "Rust and PostgreSQL",
5650 "dockerComposeFile": "docker-compose.yml",
5651 "service": "app",
5652 "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
5653
5654 // Features to add to the dev container. More info: https://containers.dev/features.
5655 // "features": {},
5656
5657 // Use 'forwardPorts' to make a list of ports inside the container available locally.
5658 "forwardPorts": [
5659 8083,
5660 "db:5432",
5661 "db:1234",
5662 ],
5663 "updateRemoteUserUID": false,
5664 "appPort": "8084",
5665
5666 // Use 'postCreateCommand' to run commands after the container is created.
5667 // "postCreateCommand": "rustc --version",
5668
5669 // Configure tool-specific properties.
5670 // "customizations": {},
5671
5672 // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
5673 // "remoteUser": "root"
5674 }
5675 "#;
5676 let (test_dependencies, mut devcontainer_manifest) =
5677 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
5678 .await
5679 .unwrap();
5680
5681 test_dependencies
5682 .fs
5683 .atomic_write(
5684 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"),
5685 r#"
5686version: '3.8'
5687
5688volumes:
5689postgres-data:
5690
5691services:
5692app:
5693 build:
5694 context: .
5695 dockerfile: Dockerfile
5696 env_file:
5697 # Ensure that the variables in .env match the same variables in devcontainer.json
5698 - .env
5699
5700 volumes:
5701 - ../..:/workspaces:cached
5702
5703 # Overrides default command so things don't shut down after the process ends.
5704 command: sleep infinity
5705
5706 # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
5707 network_mode: service:db
5708
5709 # Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
5710 # (Adding the "ports" property to this file will not forward from a Codespace.)
5711
5712db:
5713 image: postgres:14.1
5714 restart: unless-stopped
5715 volumes:
5716 - postgres-data:/var/lib/postgresql/data
5717 env_file:
5718 # Ensure that the variables in .env match the same variables in devcontainer.json
5719 - .env
5720
5721 # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
5722 # (Adding the "ports" property to this file will not forward from a Codespace.)
5723 "#.trim().to_string(),
5724 )
5725 .await
5726 .unwrap();
5727
5728 test_dependencies.fs.atomic_write(
5729 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"),
5730 r#"
5731FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm
5732
5733# Include lld linker to improve build times either by using environment variable
5734# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml).
5735RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
5736&& apt-get -y install clang lld \
5737&& apt-get autoremove -y && apt-get clean -y
5738 "#.trim().to_string()).await.unwrap();
5739
5740 devcontainer_manifest.parse_nonremote_vars().unwrap();
5741
5742 let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
5743
5744 let files = test_dependencies.fs.files();
5745 let feature_dockerfile = files
5746 .iter()
5747 .find(|f| {
5748 f.file_name()
5749 .is_some_and(|s| s.display().to_string() == "Dockerfile.extended")
5750 })
5751 .expect("to be found");
5752 let feature_dockerfile = test_dependencies.fs.load(feature_dockerfile).await.unwrap();
5753 assert_eq!(
5754 &feature_dockerfile,
5755 r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
5756
5757FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm AS dev_container_auto_added_stage_label
5758
5759# Include lld linker to improve build times either by using environment variable
5760# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml).
5761RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
5762&& apt-get -y install clang lld \
5763&& apt-get autoremove -y && apt-get clean -y
5764
5765FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize
5766USER root
5767COPY --from=dev_containers_feature_content_source ./devcontainer-features.builtin.env /tmp/build-features/
5768RUN chmod -R 0755 /tmp/build-features/
5769
5770FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage
5771
5772USER root
5773
5774RUN mkdir -p /tmp/dev-container-features
5775COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ /tmp/dev-container-features
5776
5777RUN \
5778echo "_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 && \
5779echo "_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
5780
5781
5782RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./aws-cli_0,target=/tmp/build-features-src/aws-cli_0 \
5783cp -ar /tmp/build-features-src/aws-cli_0 /tmp/dev-container-features \
5784&& chmod -R 0755 /tmp/dev-container-features/aws-cli_0 \
5785&& cd /tmp/dev-container-features/aws-cli_0 \
5786&& chmod +x ./devcontainer-features-install.sh \
5787&& ./devcontainer-features-install.sh \
5788&& rm -rf /tmp/dev-container-features/aws-cli_0
5789
5790RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./docker-in-docker_1,target=/tmp/build-features-src/docker-in-docker_1 \
5791cp -ar /tmp/build-features-src/docker-in-docker_1 /tmp/dev-container-features \
5792&& chmod -R 0755 /tmp/dev-container-features/docker-in-docker_1 \
5793&& cd /tmp/dev-container-features/docker-in-docker_1 \
5794&& chmod +x ./devcontainer-features-install.sh \
5795&& ./devcontainer-features-install.sh \
5796&& rm -rf /tmp/dev-container-features/docker-in-docker_1
5797
5798
5799ARG _DEV_CONTAINERS_IMAGE_USER=root
5800USER $_DEV_CONTAINERS_IMAGE_USER
5801
5802# Ensure that /etc/profile does not clobber the existing path
5803RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
5804
5805
5806ENV DOCKER_BUILDKIT=1
5807"#
5808 );
5809 }
5810
5811 #[gpui::test]
5812 async fn test_spawns_only_requested_compose_services(cx: &mut TestAppContext) {
5813 cx.executor().allow_parking();
5814 env_logger::try_init().ok();
5815 let given_devcontainer_contents = r#"
5816 {
5817 "name": "Devcontainer and PostgreSQL",
5818 "dockerComposeFile": "docker-compose.yml",
5819 "service": "devcontainer",
5820 "runServices": ["devcontainer", "db"],
5821 "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
5822 "updateRemoteUserUID": false
5823 }
5824 "#;
5825 let (test_dependencies, mut devcontainer_manifest) =
5826 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
5827 .await
5828 .unwrap();
5829
5830 test_dependencies
5831 .fs
5832 .atomic_write(
5833 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"),
5834 r#"
5835version: '3.8'
5836
5837x-base: &base
5838 build:
5839 context: .
5840 dockerfile: Dockerfile
5841 env_file:
5842 - .env
5843
5844volumes:
5845 postgres-data:
5846
5847services:
5848 app:
5849 <<: *base
5850 ports:
5851 - "3000:3000"
5852
5853 devcontainer:
5854 <<: *base
5855 ports:
5856 - "3000:3000"
5857 volumes:
5858 - ../..:/workspaces:cached
5859
5860 db:
5861 image: postgres:14.1
5862 restart: unless-stopped
5863 volumes:
5864 - postgres-data:/var/lib/postgresql/data
5865 env_file:
5866 - .env
5867 "#
5868 .trim()
5869 .to_string(),
5870 )
5871 .await
5872 .unwrap();
5873
5874 test_dependencies
5875 .fs
5876 .atomic_write(
5877 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"),
5878 r#"
5879FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm
5880
5881RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
5882&& apt-get -y install clang lld \
5883&& apt-get autoremove -y && apt-get clean -y
5884 "#
5885 .trim()
5886 .to_string(),
5887 )
5888 .await
5889 .unwrap();
5890
5891 devcontainer_manifest.parse_nonremote_vars().unwrap();
5892 let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
5893
5894 let docker_commands = test_dependencies
5895 .command_runner
5896 .commands_by_program("docker");
5897 let compose_up = docker_commands
5898 .iter()
5899 .find(|c| {
5900 c.args.first().map(String::as_str) == Some("compose")
5901 && c.args.iter().any(|a| a == "up")
5902 })
5903 .expect("docker compose up command recorded");
5904 assert!(
5905 compose_up.args.ends_with(&[
5906 "up".to_string(),
5907 "-d".to_string(),
5908 "devcontainer".to_string(),
5909 "db".to_string(),
5910 ]),
5911 "compose up should target only the requested service, got: {:?}",
5912 compose_up.args
5913 );
5914 }
5915
5916 #[gpui::test]
5917 async fn test_resumes_only_requested_compose_services_without_recreating(
5918 cx: &mut TestAppContext,
5919 ) {
5920 let devcontainer_contents = r#"
5921 {
5922 "dockerComposeFile": "docker-compose.yml",
5923 "service": "devcontainer",
5924 "runServices": ["devcontainer", "db"],
5925 "workspaceFolder": "/workspaces/project",
5926 "updateRemoteUserUID": false
5927 }
5928 "#;
5929 let (test_dependencies, mut devcontainer_manifest) =
5930 init_default_devcontainer_manifest(cx, devcontainer_contents)
5931 .await
5932 .unwrap();
5933
5934 test_dependencies
5935 .fs
5936 .atomic_write(
5937 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"),
5938 indoc! {r#"
5939 services:
5940 app:
5941 image: test_image:latest
5942 devcontainer:
5943 image: test_image:latest
5944 db:
5945 image: postgres:18.4
5946 "# }
5947 .to_string(),
5948 )
5949 .await
5950 .unwrap();
5951
5952 devcontainer_manifest.parse_nonremote_vars().unwrap();
5953
5954 assert!(
5955 devcontainer_manifest
5956 .check_for_existing_devcontainer()
5957 .await
5958 .unwrap()
5959 .is_some()
5960 );
5961
5962 let command = test_dependencies
5963 .command_runner
5964 .commands_by_program("docker")
5965 .into_iter()
5966 .find(|command| {
5967 command.args.first().map(String::as_str) == Some("compose")
5968 && command.args.iter().any(|argument| argument == "up")
5969 })
5970 .expect("docker compose up command recorded");
5971
5972 assert!(
5973 command.args.ends_with(&[
5974 "up".to_string(),
5975 "-d".to_string(),
5976 "--no-recreate".to_string(),
5977 "devcontainer".to_string(),
5978 "db".to_string(),
5979 ]),
5980 "compose resume should target requested services without recreating them, got: {:?}",
5981 command.args
5982 );
5983 }
5984
5985 #[cfg(not(target_os = "windows"))]
5986 #[gpui::test]
5987 async fn test_spawns_devcontainer_with_docker_compose_and_podman(cx: &mut TestAppContext) {
5988 cx.executor().allow_parking();
5989 env_logger::try_init().ok();
5990 let given_devcontainer_contents = r#"
5991 // For format details, see https://aka.ms/devcontainer.json. For config options, see the
5992 // README at: https://github.com/devcontainers/templates/tree/main/src/rust-postgres
5993 {
5994 "features": {
5995 "ghcr.io/devcontainers/features/aws-cli:1": {},
5996 "ghcr.io/devcontainers/features/docker-in-docker:2": {},
5997 },
5998 "name": "Rust and PostgreSQL",
5999 "dockerComposeFile": "docker-compose.yml",
6000 "service": "app",
6001 "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
6002
6003 // Features to add to the dev container. More info: https://containers.dev/features.
6004 // "features": {},
6005
6006 // Use 'forwardPorts' to make a list of ports inside the container available locally.
6007 // "forwardPorts": [5432],
6008
6009 // Use 'postCreateCommand' to run commands after the container is created.
6010 // "postCreateCommand": "rustc --version",
6011
6012 // Configure tool-specific properties.
6013 // "customizations": {},
6014
6015 // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
6016 // "remoteUser": "root"
6017 }
6018 "#;
6019 let mut fake_docker = FakeDocker::new();
6020 fake_docker.set_podman(true);
6021 let (test_dependencies, mut devcontainer_manifest) = init_devcontainer_manifest(
6022 cx,
6023 FakeFs::new(cx.executor()),
6024 fake_http_client(),
6025 Arc::new(fake_docker),
6026 Arc::new(TestCommandRunner::new()),
6027 HashMap::new(),
6028 given_devcontainer_contents,
6029 )
6030 .await
6031 .unwrap();
6032
6033 test_dependencies
6034 .fs
6035 .atomic_write(
6036 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose.yml"),
6037 r#"
6038version: '3.8'
6039
6040volumes:
6041postgres-data:
6042
6043services:
6044app:
6045build:
6046 context: .
6047 dockerfile: Dockerfile
6048env_file:
6049 # Ensure that the variables in .env match the same variables in devcontainer.json
6050 - .env
6051
6052volumes:
6053 - ../..:/workspaces:cached
6054
6055# Overrides default command so things don't shut down after the process ends.
6056command: sleep infinity
6057
6058# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
6059network_mode: service:db
6060
6061# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
6062# (Adding the "ports" property to this file will not forward from a Codespace.)
6063
6064db:
6065image: postgres:14.1
6066restart: unless-stopped
6067volumes:
6068 - postgres-data:/var/lib/postgresql/data
6069env_file:
6070 # Ensure that the variables in .env match the same variables in devcontainer.json
6071 - .env
6072
6073# Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
6074# (Adding the "ports" property to this file will not forward from a Codespace.)
6075 "#.trim().to_string(),
6076 )
6077 .await
6078 .unwrap();
6079
6080 test_dependencies.fs.atomic_write(
6081 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"),
6082 r#"
6083FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm
6084
6085# Include lld linker to improve build times either by using environment variable
6086# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml).
6087RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
6088&& apt-get -y install clang lld \
6089&& apt-get autoremove -y && apt-get clean -y
6090 "#.trim().to_string()).await.unwrap();
6091
6092 devcontainer_manifest.parse_nonremote_vars().unwrap();
6093
6094 let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
6095
6096 let files = test_dependencies.fs.files();
6097
6098 let feature_dockerfile = files
6099 .iter()
6100 .find(|f| {
6101 f.file_name()
6102 .is_some_and(|s| s.display().to_string() == "Dockerfile.extended")
6103 })
6104 .expect("to be found");
6105 let feature_dockerfile = test_dependencies.fs.load(feature_dockerfile).await.unwrap();
6106 assert_eq!(
6107 &feature_dockerfile,
6108 r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
6109
6110FROM mcr.microsoft.com/devcontainers/rust:2-1-bookworm AS dev_container_auto_added_stage_label
6111
6112# Include lld linker to improve build times either by using environment variable
6113# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml).
6114RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
6115&& apt-get -y install clang lld \
6116&& apt-get autoremove -y && apt-get clean -y
6117
6118FROM dev_container_feature_content_temp as dev_containers_feature_content_source
6119
6120FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize
6121USER root
6122COPY --from=dev_containers_feature_content_source /tmp/build-features/devcontainer-features.builtin.env /tmp/build-features/
6123RUN chmod -R 0755 /tmp/build-features/
6124
6125FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage
6126
6127USER root
6128
6129RUN mkdir -p /tmp/dev-container-features
6130COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ /tmp/dev-container-features
6131
6132RUN \
6133echo "_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 && \
6134echo "_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
6135
6136
6137COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/aws-cli_0 /tmp/dev-container-features/aws-cli_0
6138RUN chmod -R 0755 /tmp/dev-container-features/aws-cli_0 \
6139&& cd /tmp/dev-container-features/aws-cli_0 \
6140&& chmod +x ./devcontainer-features-install.sh \
6141&& ./devcontainer-features-install.sh
6142
6143COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/docker-in-docker_1 /tmp/dev-container-features/docker-in-docker_1
6144RUN chmod -R 0755 /tmp/dev-container-features/docker-in-docker_1 \
6145&& cd /tmp/dev-container-features/docker-in-docker_1 \
6146&& chmod +x ./devcontainer-features-install.sh \
6147&& ./devcontainer-features-install.sh
6148
6149
6150ARG _DEV_CONTAINERS_IMAGE_USER=root
6151USER $_DEV_CONTAINERS_IMAGE_USER
6152"#
6153 );
6154
6155 let uid_dockerfile = files
6156 .iter()
6157 .find(|f| {
6158 f.file_name()
6159 .is_some_and(|s| s.display().to_string() == "updateUID.Dockerfile")
6160 })
6161 .expect("to be found");
6162 let uid_dockerfile = test_dependencies.fs.load(uid_dockerfile).await.unwrap();
6163
6164 assert_eq!(
6165 &uid_dockerfile,
6166 r#"ARG BASE_IMAGE
6167FROM $BASE_IMAGE
6168
6169USER root
6170
6171ARG REMOTE_USER
6172ARG NEW_UID
6173ARG NEW_GID
6174SHELL ["/bin/sh", "-c"]
6175RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/OLD_UID=\1;OLD_GID=\2;HOME_FOLDER=\3/p" /etc/passwd); \
6176 eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_UID}:.*/EXISTING_USER=\1/p" /etc/passwd); \
6177 eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_GID}:.*/EXISTING_GROUP=\1/p" /etc/group); \
6178 if [ -z "$OLD_UID" ]; then \
6179 echo "Remote user not found in /etc/passwd ($REMOTE_USER)."; \
6180 elif [ "$OLD_UID" = "$NEW_UID" -a "$OLD_GID" = "$NEW_GID" ]; then \
6181 echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \
6182 elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \
6183 echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \
6184 else \
6185 if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \
6186 FREE_GID=65532; \
6187 while grep -q ":[^:]*:${FREE_GID}:" /etc/group; do FREE_GID=$((FREE_GID - 1)); done; \
6188 echo "Reassigning group $EXISTING_GROUP from GID $NEW_GID to $FREE_GID."; \
6189 sed -i -e "s/\(${EXISTING_GROUP}:[^:]*:\)${NEW_GID}:/\1${FREE_GID}:/" /etc/group; \
6190 fi; \
6191 echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \
6192 sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \
6193 if [ "$OLD_GID" != "$NEW_GID" ]; then \
6194 sed -i -e "s/\([^:]*:[^:]*:\)${OLD_GID}:/\1${NEW_GID}:/" /etc/group; \
6195 fi; \
6196 chown -R $NEW_UID:$NEW_GID $HOME_FOLDER; \
6197 fi;
6198
6199ARG IMAGE_USER
6200USER $IMAGE_USER
6201
6202# Ensure that /etc/profile does not clobber the existing path
6203RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
6204
6205
6206ENV DOCKER_BUILDKIT=1
6207"#
6208 );
6209 }
6210
6211 #[gpui::test]
6212 async fn test_spawns_devcontainer_with_dockerfile_and_no_update_uid(cx: &mut TestAppContext) {
6213 cx.executor().allow_parking();
6214 env_logger::try_init().ok();
6215 let given_devcontainer_contents = r#"
6216 /*---------------------------------------------------------------------------------------------
6217 * Copyright (c) Microsoft Corporation. All rights reserved.
6218 * Licensed under the MIT License. See License.txt in the project root for license information.
6219 *--------------------------------------------------------------------------------------------*/
6220 {
6221 "name": "cli-${devcontainerId}",
6222 // "image": "mcr.microsoft.com/devcontainers/typescript-node:16-bullseye",
6223 "build": {
6224 "dockerfile": "Dockerfile",
6225 "args": {
6226 "VARIANT": "18-bookworm",
6227 "FOO": "bar",
6228 },
6229 "target": "development",
6230 },
6231 "workspaceMount": "source=${localWorkspaceFolder},target=${containerWorkspaceFolder},type=bind,consistency=cached",
6232 "workspaceFolder": "/workspace2",
6233 "mounts": [
6234 // Keep command history across instances
6235 "source=dev-containers-cli-bashhistory,target=/home/node/commandhistory",
6236 ],
6237
6238 "forwardPorts": [
6239 8082,
6240 8083,
6241 ],
6242 "appPort": "8084",
6243 "updateRemoteUserUID": false,
6244
6245 "containerEnv": {
6246 "VARIABLE_VALUE": "value",
6247 },
6248
6249 "initializeCommand": "touch IAM.md",
6250
6251 "onCreateCommand": "echo 'onCreateCommand' >> ON_CREATE_COMMAND.md",
6252
6253 "updateContentCommand": "echo 'updateContentCommand' >> UPDATE_CONTENT_COMMAND.md",
6254
6255 "postCreateCommand": {
6256 "yarn": "yarn install",
6257 "debug": "echo 'postStartCommand' >> POST_START_COMMAND.md",
6258 },
6259
6260 "postStartCommand": "echo 'postStartCommand' >> POST_START_COMMAND.md",
6261
6262 "postAttachCommand": "echo 'postAttachCommand' >> POST_ATTACH_COMMAND.md",
6263
6264 "remoteUser": "node",
6265
6266 "remoteEnv": {
6267 "PATH": "${containerEnv:PATH}:/some/other/path",
6268 "OTHER_ENV": "other_env_value"
6269 },
6270
6271 "features": {
6272 "ghcr.io/devcontainers/features/docker-in-docker:2": {
6273 "moby": false,
6274 },
6275 "ghcr.io/devcontainers/features/go:1": {},
6276 },
6277
6278 "customizations": {
6279 "vscode": {
6280 "extensions": [
6281 "dbaeumer.vscode-eslint",
6282 "GitHub.vscode-pull-request-github",
6283 ],
6284 },
6285 "zed": {
6286 "extensions": ["vue", "ruby"],
6287 },
6288 "codespaces": {
6289 "repositories": {
6290 "devcontainers/features": {
6291 "permissions": {
6292 "contents": "write",
6293 "workflows": "write",
6294 },
6295 },
6296 },
6297 },
6298 },
6299 }
6300 "#;
6301
6302 let (test_dependencies, mut devcontainer_manifest) =
6303 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
6304 .await
6305 .unwrap();
6306
6307 test_dependencies
6308 .fs
6309 .atomic_write(
6310 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"),
6311 r#"
6312# Copyright (c) Microsoft Corporation. All rights reserved.
6313# Licensed under the MIT License. See License.txt in the project root for license information.
6314ARG VARIANT="16-bullseye"
6315FROM mcr.microsoft.com/devcontainers/typescript-node:latest as predev
6316FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} as development
6317
6318RUN mkdir -p /workspaces && chown node:node /workspaces
6319
6320ARG USERNAME=node
6321USER $USERNAME
6322
6323# Save command line history
6324RUN echo "export HISTFILE=/home/$USERNAME/commandhistory/.bash_history" >> "/home/$USERNAME/.bashrc" \
6325&& echo "export PROMPT_COMMAND='history -a'" >> "/home/$USERNAME/.bashrc" \
6326&& mkdir -p /home/$USERNAME/commandhistory \
6327&& touch /home/$USERNAME/commandhistory/.bash_history \
6328&& chown -R $USERNAME /home/$USERNAME/commandhistory
6329 "#.trim().to_string(),
6330 )
6331 .await
6332 .unwrap();
6333
6334 devcontainer_manifest.parse_nonremote_vars().unwrap();
6335
6336 let devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
6337
6338 assert_eq!(
6339 devcontainer_up.extension_ids,
6340 vec!["vue".to_string(), "ruby".to_string()]
6341 );
6342
6343 let files = test_dependencies.fs.files();
6344 let feature_dockerfile = files
6345 .iter()
6346 .find(|f| {
6347 f.file_name()
6348 .is_some_and(|s| s.display().to_string() == "Dockerfile.extended")
6349 })
6350 .expect("to be found");
6351 let feature_dockerfile = test_dependencies.fs.load(feature_dockerfile).await.unwrap();
6352 assert_eq!(
6353 &feature_dockerfile,
6354 r#"ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
6355
6356# Copyright (c) Microsoft Corporation. All rights reserved.
6357# Licensed under the MIT License. See License.txt in the project root for license information.
6358ARG VARIANT="16-bullseye"
6359FROM mcr.microsoft.com/devcontainers/typescript-node:latest as predev
6360FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} as development
6361
6362RUN mkdir -p /workspaces && chown node:node /workspaces
6363
6364ARG USERNAME=node
6365USER $USERNAME
6366
6367# Save command line history
6368RUN echo "export HISTFILE=/home/$USERNAME/commandhistory/.bash_history" >> "/home/$USERNAME/.bashrc" \
6369&& echo "export PROMPT_COMMAND='history -a'" >> "/home/$USERNAME/.bashrc" \
6370&& mkdir -p /home/$USERNAME/commandhistory \
6371&& touch /home/$USERNAME/commandhistory/.bash_history \
6372&& chown -R $USERNAME /home/$USERNAME/commandhistory
6373FROM development AS dev_container_auto_added_stage_label
6374
6375FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize
6376USER root
6377COPY --from=dev_containers_feature_content_source ./devcontainer-features.builtin.env /tmp/build-features/
6378RUN chmod -R 0755 /tmp/build-features/
6379
6380FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage
6381
6382USER root
6383
6384RUN mkdir -p /tmp/dev-container-features
6385COPY --from=dev_containers_feature_content_normalize /tmp/build-features/ /tmp/dev-container-features
6386
6387RUN \
6388echo "_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 && \
6389echo "_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
6390
6391
6392RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./docker-in-docker_0,target=/tmp/build-features-src/docker-in-docker_0 \
6393cp -ar /tmp/build-features-src/docker-in-docker_0 /tmp/dev-container-features \
6394&& chmod -R 0755 /tmp/dev-container-features/docker-in-docker_0 \
6395&& cd /tmp/dev-container-features/docker-in-docker_0 \
6396&& chmod +x ./devcontainer-features-install.sh \
6397&& ./devcontainer-features-install.sh \
6398&& rm -rf /tmp/dev-container-features/docker-in-docker_0
6399
6400RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./go_1,target=/tmp/build-features-src/go_1 \
6401cp -ar /tmp/build-features-src/go_1 /tmp/dev-container-features \
6402&& chmod -R 0755 /tmp/dev-container-features/go_1 \
6403&& cd /tmp/dev-container-features/go_1 \
6404&& chmod +x ./devcontainer-features-install.sh \
6405&& ./devcontainer-features-install.sh \
6406&& rm -rf /tmp/dev-container-features/go_1
6407
6408
6409ARG _DEV_CONTAINERS_IMAGE_USER=root
6410USER $_DEV_CONTAINERS_IMAGE_USER
6411
6412# Ensure that /etc/profile does not clobber the existing path
6413RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
6414
6415ENV DOCKER_BUILDKIT=1
6416
6417ENV GOPATH=/go
6418ENV GOROOT=/usr/local/go
6419ENV PATH=/usr/local/go/bin:/go/bin:${PATH}
6420ENV VARIABLE_VALUE=value
6421"#
6422 );
6423
6424 let golang_install_wrapper = files
6425 .iter()
6426 .find(|f| {
6427 f.file_name()
6428 .is_some_and(|s| s.display().to_string() == "devcontainer-features-install.sh")
6429 && f.to_str().is_some_and(|s| s.contains("go_"))
6430 })
6431 .expect("to be found");
6432 let golang_install_wrapper = test_dependencies
6433 .fs
6434 .load(golang_install_wrapper)
6435 .await
6436 .unwrap();
6437 assert_eq!(
6438 &golang_install_wrapper,
6439 r#"#!/bin/sh
6440set -e
6441
6442on_exit () {
6443 [ $? -eq 0 ] && exit
6444 echo 'ERROR: Feature "go" (ghcr.io/devcontainers/features/go:1) failed to install!'
6445}
6446
6447trap on_exit EXIT
6448
6449echo ===========================================================================
6450echo 'Feature : go'
6451echo 'Id : ghcr.io/devcontainers/features/go:1'
6452echo 'Options :'
6453echo ' GOLANGCILINTVERSION=latest
6454 VERSION=latest'
6455echo ===========================================================================
6456
6457set -a
6458. ../devcontainer-features.builtin.env
6459. ./devcontainer-features.env
6460set +a
6461
6462chmod +x ./install.sh
6463./install.sh
6464"#
6465 );
6466
6467 let docker_commands = test_dependencies
6468 .command_runner
6469 .commands_by_program("docker");
6470
6471 let docker_run_command = docker_commands
6472 .iter()
6473 .find(|c| c.args.get(0).is_some_and(|a| a == "run"));
6474
6475 assert!(docker_run_command.is_some());
6476
6477 let docker_exec_commands = test_dependencies
6478 .docker
6479 .exec_commands_recorded
6480 .lock()
6481 .unwrap();
6482
6483 assert!(docker_exec_commands.iter().all(|exec| {
6484 exec.env
6485 == HashMap::from([
6486 ("OTHER_ENV".to_string(), "other_env_value".to_string()),
6487 (
6488 "PATH".to_string(),
6489 "/initial/path:/some/other/path".to_string(),
6490 ),
6491 ])
6492 }))
6493 }
6494
6495 #[cfg(not(target_os = "windows"))]
6496 #[gpui::test]
6497 async fn test_spawns_devcontainer_with_local_feature(cx: &mut TestAppContext) {
6498 cx.executor().allow_parking();
6499 env_logger::try_init().ok();
6500 let given_devcontainer_contents = r#"
6501 {
6502 "name": "cli-local-feature-test",
6503 "image": "test_image:latest",
6504 "features": {
6505 "./lsp-devtools": {
6506 "version": "0.1.0"
6507 }
6508 }
6509 }
6510 "#;
6511
6512 let (test_dependencies, mut devcontainer_manifest) =
6513 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
6514 .await
6515 .unwrap();
6516
6517 test_dependencies
6518 .fs
6519 .insert_tree(
6520 format!("{TEST_PROJECT_PATH}/.devcontainer/lsp-devtools"),
6521 serde_json::json!({
6522 "devcontainer-feature.json": r#"{
6523 "id": "lsp-devtools",
6524 "version": "0.1.0",
6525 "name": "LSP Devtools",
6526 "options": {
6527 "version": {
6528 "type": "string",
6529 "default": "latest"
6530 }
6531 }
6532 }"#,
6533 "install.sh": "#!/bin/sh\nset -e\necho 'Installing lsp-devtools'",
6534 }),
6535 )
6536 .await;
6537
6538 devcontainer_manifest.parse_nonremote_vars().unwrap();
6539
6540 let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
6541
6542 let files = test_dependencies.fs.files();
6543
6544 let feature_dockerfile = files
6545 .iter()
6546 .find(|f| {
6547 f.file_name()
6548 .is_some_and(|s| s.display().to_string() == "Dockerfile.extended")
6549 })
6550 .expect("Dockerfile.extended should be generated");
6551 let feature_dockerfile = test_dependencies.fs.load(feature_dockerfile).await.unwrap();
6552
6553 assert!(
6554 feature_dockerfile.contains("lsp-devtools_0"),
6555 "Dockerfile.extended should reference the local feature. Got:\n{}",
6556 feature_dockerfile
6557 );
6558
6559 let install_wrapper = files
6560 .iter()
6561 .find(|f| {
6562 f.file_name()
6563 .is_some_and(|s| s.display().to_string() == "devcontainer-features-install.sh")
6564 && f.to_str().is_some_and(|s| s.contains("/lsp-devtools_"))
6565 })
6566 .expect("Install wrapper should be generated for local feature");
6567 let install_wrapper = test_dependencies.fs.load(install_wrapper).await.unwrap();
6568 assert!(
6569 install_wrapper.contains("./lsp-devtools"),
6570 "Install wrapper should reference the local feature path. Got:\n{}",
6571 install_wrapper
6572 );
6573
6574 let feature_env = files
6575 .iter()
6576 .find(|f| {
6577 f.file_name()
6578 .is_some_and(|s| s.display().to_string() == "devcontainer-features.env")
6579 && f.to_str().is_some_and(|s| s.contains("/lsp-devtools_"))
6580 })
6581 .expect("Feature env file should be generated for local feature");
6582 let feature_env = test_dependencies.fs.load(feature_env).await.unwrap();
6583 assert!(
6584 feature_env.contains("VERSION=0.1.0"),
6585 "Feature env should contain user-provided version override. Got:\n{}",
6586 feature_env
6587 );
6588
6589 let install_sh = files
6590 .iter()
6591 .find(|f| {
6592 f.file_name()
6593 .is_some_and(|s| s.display().to_string() == "install.sh")
6594 && f.to_str().is_some_and(|s| s.contains("/lsp-devtools_"))
6595 })
6596 .expect("install.sh should be copied from the local feature directory");
6597 let install_sh = test_dependencies.fs.load(install_sh).await.unwrap();
6598 assert!(
6599 install_sh.contains("Installing lsp-devtools"),
6600 "install.sh should have the original content. Got:\n{}",
6601 install_sh
6602 );
6603 }
6604
6605 #[cfg(not(target_os = "windows"))]
6606 #[gpui::test]
6607 async fn test_spawns_devcontainer_with_plain_image(cx: &mut TestAppContext) {
6608 cx.executor().allow_parking();
6609 env_logger::try_init().ok();
6610 let given_devcontainer_contents = r#"
6611 {
6612 "name": "cli-${devcontainerId}",
6613 "image": "test_image:latest",
6614 }
6615 "#;
6616
6617 let (test_dependencies, mut devcontainer_manifest) =
6618 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
6619 .await
6620 .unwrap();
6621
6622 devcontainer_manifest.parse_nonremote_vars().unwrap();
6623
6624 let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
6625
6626 let files = test_dependencies.fs.files();
6627 let uid_dockerfile = files
6628 .iter()
6629 .find(|f| {
6630 f.file_name()
6631 .is_some_and(|s| s.display().to_string() == "updateUID.Dockerfile")
6632 })
6633 .expect("to be found");
6634 let uid_dockerfile = test_dependencies.fs.load(uid_dockerfile).await.unwrap();
6635
6636 assert_eq!(
6637 &uid_dockerfile,
6638 r#"ARG BASE_IMAGE
6639FROM $BASE_IMAGE
6640
6641USER root
6642
6643ARG REMOTE_USER
6644ARG NEW_UID
6645ARG NEW_GID
6646SHELL ["/bin/sh", "-c"]
6647RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/OLD_UID=\1;OLD_GID=\2;HOME_FOLDER=\3/p" /etc/passwd); \
6648 eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_UID}:.*/EXISTING_USER=\1/p" /etc/passwd); \
6649 eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_GID}:.*/EXISTING_GROUP=\1/p" /etc/group); \
6650 if [ -z "$OLD_UID" ]; then \
6651 echo "Remote user not found in /etc/passwd ($REMOTE_USER)."; \
6652 elif [ "$OLD_UID" = "$NEW_UID" -a "$OLD_GID" = "$NEW_GID" ]; then \
6653 echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \
6654 elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \
6655 echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \
6656 else \
6657 if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \
6658 FREE_GID=65532; \
6659 while grep -q ":[^:]*:${FREE_GID}:" /etc/group; do FREE_GID=$((FREE_GID - 1)); done; \
6660 echo "Reassigning group $EXISTING_GROUP from GID $NEW_GID to $FREE_GID."; \
6661 sed -i -e "s/\(${EXISTING_GROUP}:[^:]*:\)${NEW_GID}:/\1${FREE_GID}:/" /etc/group; \
6662 fi; \
6663 echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \
6664 sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \
6665 if [ "$OLD_GID" != "$NEW_GID" ]; then \
6666 sed -i -e "s/\([^:]*:[^:]*:\)${OLD_GID}:/\1${NEW_GID}:/" /etc/group; \
6667 fi; \
6668 chown -R $NEW_UID:$NEW_GID $HOME_FOLDER; \
6669 fi;
6670
6671ARG IMAGE_USER
6672USER $IMAGE_USER
6673
6674# Ensure that /etc/profile does not clobber the existing path
6675RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
6676"#
6677 );
6678 }
6679
6680 #[cfg(target_os = "windows")]
6681 #[gpui::test]
6682 async fn test_spawns_devcontainer_with_plain_image(cx: &mut TestAppContext) {
6683 cx.executor().allow_parking();
6684 env_logger::try_init().ok();
6685 let given_devcontainer_contents = r#"
6686 {
6687 "name": "cli-${devcontainerId}",
6688 "image": "test_image:latest",
6689 }
6690 "#;
6691
6692 let (_, mut devcontainer_manifest) =
6693 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
6694 .await
6695 .unwrap();
6696
6697 devcontainer_manifest.parse_nonremote_vars().unwrap();
6698
6699 let devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
6700
6701 assert_eq!(
6702 devcontainer_up.remote_workspace_folder,
6703 "/workspaces/project"
6704 );
6705 }
6706
6707 #[cfg(not(target_os = "windows"))]
6708 #[gpui::test]
6709 async fn test_spawns_devcontainer_with_docker_compose_and_plain_image(cx: &mut TestAppContext) {
6710 cx.executor().allow_parking();
6711 env_logger::try_init().ok();
6712 let given_devcontainer_contents = r#"
6713 {
6714 "name": "cli-${devcontainerId}",
6715 "dockerComposeFile": "docker-compose-plain.yml",
6716 "service": "app",
6717 }
6718 "#;
6719
6720 let (test_dependencies, mut devcontainer_manifest) =
6721 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
6722 .await
6723 .unwrap();
6724
6725 test_dependencies
6726 .fs
6727 .atomic_write(
6728 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/docker-compose-plain.yml"),
6729 r#"
6730services:
6731 app:
6732 image: test_image:latest
6733 command: sleep infinity
6734 volumes:
6735 - ..:/workspace:cached
6736 "#
6737 .trim()
6738 .to_string(),
6739 )
6740 .await
6741 .unwrap();
6742
6743 devcontainer_manifest.parse_nonremote_vars().unwrap();
6744
6745 let _devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
6746
6747 let files = test_dependencies.fs.files();
6748 let uid_dockerfile = files
6749 .iter()
6750 .find(|f| {
6751 f.file_name()
6752 .is_some_and(|s| s.display().to_string() == "updateUID.Dockerfile")
6753 })
6754 .expect("to be found");
6755 let uid_dockerfile = test_dependencies.fs.load(uid_dockerfile).await.unwrap();
6756
6757 assert_eq!(
6758 &uid_dockerfile,
6759 r#"ARG BASE_IMAGE
6760FROM $BASE_IMAGE
6761
6762USER root
6763
6764ARG REMOTE_USER
6765ARG NEW_UID
6766ARG NEW_GID
6767SHELL ["/bin/sh", "-c"]
6768RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/OLD_UID=\1;OLD_GID=\2;HOME_FOLDER=\3/p" /etc/passwd); \
6769 eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_UID}:.*/EXISTING_USER=\1/p" /etc/passwd); \
6770 eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_GID}:.*/EXISTING_GROUP=\1/p" /etc/group); \
6771 if [ -z "$OLD_UID" ]; then \
6772 echo "Remote user not found in /etc/passwd ($REMOTE_USER)."; \
6773 elif [ "$OLD_UID" = "$NEW_UID" -a "$OLD_GID" = "$NEW_GID" ]; then \
6774 echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \
6775 elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \
6776 echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \
6777 else \
6778 if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \
6779 FREE_GID=65532; \
6780 while grep -q ":[^:]*:${FREE_GID}:" /etc/group; do FREE_GID=$((FREE_GID - 1)); done; \
6781 echo "Reassigning group $EXISTING_GROUP from GID $NEW_GID to $FREE_GID."; \
6782 sed -i -e "s/\(${EXISTING_GROUP}:[^:]*:\)${NEW_GID}:/\1${FREE_GID}:/" /etc/group; \
6783 fi; \
6784 echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \
6785 sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \
6786 if [ "$OLD_GID" != "$NEW_GID" ]; then \
6787 sed -i -e "s/\([^:]*:[^:]*:\)${OLD_GID}:/\1${NEW_GID}:/" /etc/group; \
6788 fi; \
6789 chown -R $NEW_UID:$NEW_GID $HOME_FOLDER; \
6790 fi;
6791
6792ARG IMAGE_USER
6793USER $IMAGE_USER
6794
6795# Ensure that /etc/profile does not clobber the existing path
6796RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
6797"#
6798 );
6799 }
6800
6801 #[gpui::test]
6802 async fn test_gets_base_image_from_dockerfile(cx: &mut TestAppContext) {
6803 cx.executor().allow_parking();
6804 env_logger::try_init().ok();
6805 let given_devcontainer_contents = r#"
6806 {
6807 "name": "cli-${devcontainerId}",
6808 "build": {
6809 "dockerfile": "Dockerfile",
6810 "args": {
6811 "VERSION": "1.22",
6812 }
6813 },
6814 }
6815 "#;
6816
6817 let (test_dependencies, mut devcontainer_manifest) =
6818 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
6819 .await
6820 .unwrap();
6821
6822 test_dependencies
6823 .fs
6824 .atomic_write(
6825 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"),
6826 r#"
6827FROM dontgrabme as build_context
6828ARG VERSION=1.21
6829ARG REPOSITORY=mybuild
6830ARG REGISTRY=docker.io/stuff
6831
6832ARG IMAGE=${REGISTRY}/${REPOSITORY}:${VERSION}
6833
6834FROM ${IMAGE} AS devcontainer
6835 "#
6836 .trim()
6837 .to_string(),
6838 )
6839 .await
6840 .unwrap();
6841
6842 devcontainer_manifest.parse_nonremote_vars().unwrap();
6843
6844 let dockerfile_contents = devcontainer_manifest
6845 .expanded_dockerfile_content()
6846 .await
6847 .unwrap();
6848 let base_image = image_from_dockerfile(
6849 dockerfile_contents,
6850 &devcontainer_manifest
6851 .dev_container()
6852 .build
6853 .as_ref()
6854 .and_then(|b| b.target.clone()),
6855 )
6856 .unwrap();
6857
6858 assert_eq!(base_image, "docker.io/stuff/mybuild:1.22".to_string());
6859 }
6860
6861 #[gpui::test]
6862 async fn test_gets_base_image_from_dockerfile_with_target_specified(cx: &mut TestAppContext) {
6863 cx.executor().allow_parking();
6864 env_logger::try_init().ok();
6865 let given_devcontainer_contents = r#"
6866 {
6867 "name": "cli-${devcontainerId}",
6868 "build": {
6869 "dockerfile": "Dockerfile",
6870 "args": {
6871 "VERSION": "1.22",
6872 },
6873 "target": "development"
6874 },
6875 }
6876 "#;
6877
6878 let (test_dependencies, mut devcontainer_manifest) =
6879 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
6880 .await
6881 .unwrap();
6882
6883 test_dependencies
6884 .fs
6885 .atomic_write(
6886 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"),
6887 r#"
6888FROM dontgrabme as build_context
6889ARG VERSION=1.21
6890ARG REPOSITORY=mybuild
6891ARG REGISTRY=docker.io/stuff
6892
6893ARG IMAGE=${REGISTRY}/${REPOSITORY}:${VERSION}
6894ARG DEV_IMAGE=${REGISTRY}/${REPOSITORY}:latest
6895
6896FROM ${DEV_IMAGE} AS development
6897FROM ${IMAGE} AS production
6898 "#
6899 .trim()
6900 .to_string(),
6901 )
6902 .await
6903 .unwrap();
6904
6905 devcontainer_manifest.parse_nonremote_vars().unwrap();
6906
6907 let dockerfile_contents = devcontainer_manifest
6908 .expanded_dockerfile_content()
6909 .await
6910 .unwrap();
6911 let base_image = image_from_dockerfile(
6912 dockerfile_contents,
6913 &devcontainer_manifest
6914 .dev_container()
6915 .build
6916 .as_ref()
6917 .and_then(|b| b.target.clone()),
6918 )
6919 .unwrap();
6920
6921 assert_eq!(base_image, "docker.io/stuff/mybuild:latest".to_string());
6922 }
6923
6924 #[test]
6925 fn test_image_from_dockerfile_resolves_one_hop_alias() {
6926 let dockerfile = "FROM ubuntu:24.04 AS base\nFROM base AS development".to_string();
6927 assert_eq!(
6928 image_from_dockerfile(dockerfile, &Some("development".to_string())),
6929 Some("ubuntu:24.04".to_string())
6930 );
6931 }
6932
6933 #[test]
6934 fn test_image_from_dockerfile_resolves_deep_alias_chain() {
6935 let dockerfile =
6936 "FROM ubuntu:24.04 AS base\nFROM base AS mid\nFROM mid AS development".to_string();
6937 assert_eq!(
6938 image_from_dockerfile(dockerfile, &Some("development".to_string())),
6939 Some("ubuntu:24.04".to_string())
6940 );
6941 }
6942
6943 #[test]
6944 fn test_image_from_dockerfile_no_target_resolves_alias() {
6945 let dockerfile = "FROM ubuntu:24.04 AS base\nFROM base".to_string();
6946 assert_eq!(
6947 image_from_dockerfile(dockerfile, &None),
6948 Some("ubuntu:24.04".to_string())
6949 );
6950 }
6951
6952 #[test]
6953 fn test_image_from_dockerfile_stage_alias_shadows_external_image() {
6954 // The first `a` is an external image: stage `a` isn't defined yet.
6955 // Target `a` builds from stage `b`, whose base is that external `a`.
6956 let dockerfile = "FROM a AS b\nFROM b AS a".to_string();
6957 assert_eq!(
6958 image_from_dockerfile(dockerfile, &Some("a".to_string())),
6959 Some("a".to_string())
6960 );
6961 }
6962
6963 #[test]
6964 fn test_image_from_dockerfile_only_resolves_earlier_stages() {
6965 // The first `ubuntu` is the external image, not the later stage.
6966 let dockerfile = "FROM ubuntu AS build\nFROM debian AS ubuntu".to_string();
6967 assert_eq!(
6968 image_from_dockerfile(dockerfile, &Some("build".to_string())),
6969 Some("ubuntu".to_string())
6970 );
6971 }
6972
6973 #[test]
6974 fn test_image_from_dockerfile_skips_platform_flag() {
6975 let dockerfile =
6976 "FROM --platform=linux/amd64 ubuntu:24.04 AS base\nFROM base AS development"
6977 .to_string();
6978 assert_eq!(
6979 image_from_dockerfile(dockerfile, &Some("development".to_string())),
6980 Some("ubuntu:24.04".to_string())
6981 );
6982 }
6983
6984 #[test]
6985 fn test_image_from_dockerfile_missing_target() {
6986 let dockerfile = "FROM ubuntu:24.04 AS base".to_string();
6987 assert_eq!(
6988 image_from_dockerfile(dockerfile, &Some("nonexistent".to_string())),
6989 None
6990 );
6991 }
6992
6993 #[test]
6994 fn test_image_from_dockerfile_case_insensitive_alias() {
6995 let dockerfile = "FROM ubuntu:24.04 AS Base\nFROM Base AS Development".to_string();
6996 assert_eq!(
6997 image_from_dockerfile(dockerfile, &Some("development".to_string())),
6998 Some("ubuntu:24.04".to_string())
6999 );
7000 }
7001
7002 #[test]
7003 fn test_image_from_dockerfile_scratch_base() {
7004 let dockerfile = "FROM scratch AS builder\nFROM builder AS final".to_string();
7005 assert_eq!(
7006 image_from_dockerfile(dockerfile, &Some("final".to_string())),
7007 Some("scratch".to_string())
7008 );
7009 }
7010
7011 #[gpui::test]
7012 async fn test_expands_args_in_dockerfile(cx: &mut TestAppContext) {
7013 cx.executor().allow_parking();
7014 env_logger::try_init().ok();
7015 let given_devcontainer_contents = r#"
7016 {
7017 "name": "cli-${devcontainerId}",
7018 "build": {
7019 "dockerfile": "Dockerfile",
7020 "args": {
7021 "JSON_ARG": "some-value",
7022 "ELIXIR_VERSION": "1.21",
7023 }
7024 },
7025 }
7026 "#;
7027
7028 let (test_dependencies, mut devcontainer_manifest) =
7029 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
7030 .await
7031 .unwrap();
7032
7033 test_dependencies
7034 .fs
7035 .atomic_write(
7036 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"),
7037 r#"
7038ARG INVALID_FORWARD_REFERENCE=${OTP_VERSION}
7039ARG ELIXIR_VERSION=1.20.0-rc.4
7040ARG FOO=foo BAR=bar
7041ARG FOOBAR=${FOO}${BAR}
7042ARG OTP_VERSION=28.4.1
7043ARG DEBIAN_VERSION=trixie-20260316-slim
7044ARG IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
7045ARG NESTED_MAP="{"key1": "val1", "key2": "val2"}"
7046ARG WRAPPING_MAP={"nested_map": ${NESTED_MAP}}
7047ARG FROM_JSON=${JSON_ARG}
7048
7049FROM ${IMAGE} AS devcontainer
7050 "#
7051 .trim()
7052 .to_string(),
7053 )
7054 .await
7055 .unwrap();
7056
7057 devcontainer_manifest.parse_nonremote_vars().unwrap();
7058
7059 let expanded_dockerfile = devcontainer_manifest
7060 .expanded_dockerfile_content()
7061 .await
7062 .unwrap();
7063
7064 assert_eq!(
7065 &expanded_dockerfile,
7066 r#"
7067ARG INVALID_FORWARD_REFERENCE=${OTP_VERSION}
7068ARG ELIXIR_VERSION=1.20.0-rc.4
7069ARG FOO=foo BAR=bar
7070ARG FOOBAR=foobar
7071ARG OTP_VERSION=28.4.1
7072ARG DEBIAN_VERSION=trixie-20260316-slim
7073ARG IMAGE="docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim"
7074ARG NESTED_MAP="{"key1": "val1", "key2": "val2"}"
7075ARG WRAPPING_MAP={"nested_map": {"key1": "val1", "key2": "val2"}}
7076ARG FROM_JSON=some-value
7077
7078FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS devcontainer
7079 "#
7080 .trim()
7081 )
7082 }
7083
7084 #[gpui::test]
7085 async fn test_expands_compose_service_args_in_dockerfile(cx: &mut TestAppContext) {
7086 cx.executor().allow_parking();
7087 env_logger::try_init().ok();
7088
7089 let given_devcontainer_contents = r#"
7090 {
7091 "dockerComposeFile": "docker-compose-with-args.yml",
7092 "service": "app",
7093 }
7094 "#;
7095
7096 let (test_dependencies, mut devcontainer_manifest) =
7097 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
7098 .await
7099 .unwrap();
7100
7101 test_dependencies
7102 .fs
7103 .atomic_write(
7104 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"),
7105 "FROM ${BASE_IMAGE}\nUSER root\n".to_string(),
7106 )
7107 .await
7108 .unwrap();
7109
7110 devcontainer_manifest.parse_nonremote_vars().unwrap();
7111
7112 let expanded = devcontainer_manifest
7113 .expanded_dockerfile_content()
7114 .await
7115 .unwrap();
7116
7117 assert_eq!(expanded, "FROM test_image:latest\nUSER root");
7118
7119 let base_image =
7120 image_from_dockerfile(expanded, &None).expect("base image resolves from compose args");
7121 assert_eq!(base_image, "test_image:latest");
7122 }
7123
7124 #[gpui::test]
7125 async fn test_expands_bare_dollar_args_in_dockerfile(cx: &mut TestAppContext) {
7126 cx.executor().allow_parking();
7127 env_logger::try_init().ok();
7128 let given_devcontainer_contents = r#"
7129 {
7130 "name": "ruby-devcontainer",
7131 "build": {
7132 "dockerfile": "Dockerfile",
7133 },
7134 }
7135 "#;
7136
7137 let (test_dependencies, mut devcontainer_manifest) =
7138 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
7139 .await
7140 .unwrap();
7141
7142 test_dependencies
7143 .fs
7144 .atomic_write(
7145 PathBuf::from(TEST_PROJECT_PATH).join(".devcontainer/Dockerfile"),
7146 // Mirrors real-world Dockerfiles that use bare $VAR instead of ${VAR}.
7147 // $RUBY_VERSION2 must not be partially replaced when expanding $RUBY_VERSION.
7148 r#"
7149ARG RUBY_VERSION=3.4.4
7150ARG RUBY_VERSION2=3.3.0
7151FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION
7152RUN echo $RUBY_VERSION2
7153 "#
7154 .trim()
7155 .to_string(),
7156 )
7157 .await
7158 .unwrap();
7159
7160 devcontainer_manifest.parse_nonremote_vars().unwrap();
7161
7162 let expanded = devcontainer_manifest
7163 .expanded_dockerfile_content()
7164 .await
7165 .unwrap();
7166
7167 assert_eq!(
7168 expanded,
7169 "ARG RUBY_VERSION=3.4.4\nARG RUBY_VERSION2=3.3.0\nFROM ghcr.io/rails/devcontainer/images/ruby:3.4.4\nRUN echo 3.3.0"
7170 );
7171 }
7172
7173 #[cfg(not(target_os = "windows"))]
7174 #[gpui::test]
7175 async fn check_for_existing_container_errors_when_multiple_match(cx: &mut TestAppContext) {
7176 cx.executor().allow_parking();
7177 let (test_dependencies, devcontainer_manifest) =
7178 init_default_devcontainer_manifest(cx, r#"{"image": "image"}"#)
7179 .await
7180 .unwrap();
7181 test_dependencies
7182 .docker
7183 .set_duplicate_container_ids(vec!["abc123".to_string(), "def456".to_string()]);
7184
7185 let result = devcontainer_manifest
7186 .check_for_existing_devcontainer()
7187 .await;
7188
7189 let Err(DevContainerError::MultipleMatchingContainers(ids)) = result else {
7190 panic!("expected MultipleMatchingContainers, got {result:?}");
7191 };
7192 assert_eq!(ids, vec!["abc123".to_string(), "def456".to_string()]);
7193 }
7194
7195 #[gpui::test]
7196 async fn trim_non_alphanumeric_chars_from_image_tag(cx: &mut TestAppContext) {
7197 cx.executor().allow_parking();
7198 env_logger::try_init().ok();
7199 let given_devcontainer_contents = r#"
7200 {
7201 "name": "abcde test",
7202 "image": "test_image:latest",
7203 }
7204 "#;
7205
7206 let (_, devcontainer_manifest) =
7207 init_default_devcontainer_manifest(cx, given_devcontainer_contents)
7208 .await
7209 .unwrap();
7210
7211 let image_tag = devcontainer_manifest.generate_features_image_tag("Dockerfile".to_string());
7212
7213 assert!(
7214 image_tag.starts_with("abcde-"),
7215 "expected prefix 'abcde-', got: {image_tag}"
7216 );
7217 assert!(
7218 image_tag.ends_with("-features"),
7219 "expected suffix '-features', got: {image_tag}"
7220 );
7221 }
7222
7223 #[test]
7224 fn test_aliases_dockerfile_with_pre_existing_aliases_for_build() {
7225 let dockerfile = "FROM ubuntu:24.04 AS base\nFROM base AS development";
7226
7227 assert_eq!(
7228 dockerfile_inject_alias(dockerfile, "dev_container_auto_added_stage_label", None),
7229 "FROM ubuntu:24.04 AS base\nFROM base AS development\nFROM development AS dev_container_auto_added_stage_label"
7230 );
7231 }
7232
7233 #[test]
7234 fn test_aliases_dockerfile_with_no_aliases_for_build() {
7235 let dockerfile = "FROM --platform=linux/amd64 ubuntu:24.04\nRUN echo ok";
7236
7237 assert_eq!(
7238 dockerfile_inject_alias(dockerfile, "dev_container_auto_added_stage_label", None),
7239 "FROM --platform=linux/amd64 ubuntu:24.04 AS dev_container_auto_added_stage_label\nRUN echo ok"
7240 );
7241 }
7242
7243 #[test]
7244 fn test_aliases_dockerfile_with_build_target_specified() {
7245 let dockerfile = "FROM ubuntu:24.04 AS development\nFROM ubuntu:22.04 AS production";
7246
7247 assert_eq!(
7248 dockerfile_inject_alias(
7249 dockerfile,
7250 "dev_container_auto_added_stage_label",
7251 Some("development".to_string())
7252 ),
7253 "FROM ubuntu:24.04 AS development\nFROM ubuntu:22.04 AS production\nFROM development AS dev_container_auto_added_stage_label"
7254 );
7255 }
7256
7257 #[test]
7258 fn test_aliases_dockerfile_with_missing_build_target_is_unmodified() {
7259 let dockerfile = "FROM ubuntu:24.04 AS development";
7260
7261 assert_eq!(
7262 dockerfile_inject_alias(
7263 dockerfile,
7264 "dev_container_auto_added_stage_label",
7265 Some("nonexistent".to_string())
7266 ),
7267 dockerfile
7268 );
7269 }
7270
7271 #[test]
7272 fn test_aliases_dockerfile_with_line_continuation_is_unmodified() {
7273 let dockerfile = "FROM ubuntu:24.04 \\\n --platform=linux/amd64\nRUN echo ok";
7274
7275 assert_eq!(
7276 dockerfile_inject_alias(dockerfile, "dev_container_auto_added_stage_label", None),
7277 dockerfile
7278 );
7279 }
7280
7281 pub(crate) struct RecordedExecCommand {
7282 pub(crate) _container_id: String,
7283 pub(crate) _remote_folder: String,
7284 pub(crate) _user: String,
7285 pub(crate) env: HashMap<String, String>,
7286 pub(crate) _inner_command: Command,
7287 }
7288
7289 pub(crate) struct FakeDocker {
7290 exec_commands_recorded: Mutex<Vec<RecordedExecCommand>>,
7291 podman: bool,
7292 has_buildx: bool,
7293 /// When `Some`, `find_process_by_filters` returns
7294 /// `MultipleMatchingContainers` with these IDs. Used to exercise the
7295 /// duplicate-container error path.
7296 duplicate_container_ids: Mutex<Option<Vec<String>>>,
7297 /// Records the `services` argument passed to each `docker_compose_build`
7298 /// call so tests can assert which services were built.
7299 compose_build_services: Mutex<Vec<Option<Vec<String>>>>,
7300 }
7301
7302 impl FakeDocker {
7303 pub(crate) fn new() -> Self {
7304 Self {
7305 podman: false,
7306 has_buildx: true,
7307 exec_commands_recorded: Mutex::new(Vec::new()),
7308 duplicate_container_ids: Mutex::new(None),
7309 compose_build_services: Mutex::new(Vec::new()),
7310 }
7311 }
7312
7313 fn recorded_compose_build_services(&self) -> Vec<Option<Vec<String>>> {
7314 self.compose_build_services
7315 .lock()
7316 .expect("should be available")
7317 .clone()
7318 }
7319 #[cfg(not(target_os = "windows"))]
7320 fn set_podman(&mut self, podman: bool) {
7321 self.podman = podman;
7322 }
7323 #[cfg(not(target_os = "windows"))]
7324 fn set_duplicate_container_ids(&self, ids: Vec<String>) {
7325 *self
7326 .duplicate_container_ids
7327 .lock()
7328 .expect("should be available") = Some(ids);
7329 }
7330 }
7331
7332 #[async_trait]
7333 impl DockerClient for FakeDocker {
7334 async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError> {
7335 if id == "mcr.microsoft.com/devcontainers/typescript-node:1-18-bookworm" {
7336 return Ok(DockerInspect {
7337 id: "sha256:610e6cfca95280188b021774f8cf69dd6f49bdb6eebc34c5ee2010f4d51cc104"
7338 .to_string(),
7339 config: DockerInspectConfig {
7340 labels: DockerConfigLabels {
7341 metadata: Some(vec![HashMap::from([(
7342 "remoteUser".to_string(),
7343 Value::String("node".to_string()),
7344 )])]),
7345 },
7346 env: Vec::new(),
7347 image_user: Some("root".to_string()),
7348 },
7349 mounts: None,
7350 state: None,
7351 });
7352 }
7353 if id == "mcr.microsoft.com/devcontainers/rust:2-1-bookworm" {
7354 return Ok(DockerInspect {
7355 id: "sha256:39ad1c7264794d60e3bc449d9d8877a8e486d19ad8fba80f5369def6a2408392"
7356 .to_string(),
7357 config: DockerInspectConfig {
7358 labels: DockerConfigLabels {
7359 metadata: Some(vec![
7360 HashMap::from([(
7361 "remoteUser".to_string(),
7362 Value::String("vscode".to_string()),
7363 )]),
7364 HashMap::from([
7365 (
7366 "capAdd".to_string(),
7367 Value::Array(vec![Value::String("SYS_PTRACE".to_string())]),
7368 ),
7369 (
7370 "securityOpt".to_string(),
7371 Value::Array(vec![Value::String(
7372 "seccomp=unconfined".to_string(),
7373 )]),
7374 ),
7375 ]),
7376 ]),
7377 },
7378 image_user: Some("root".to_string()),
7379 env: Vec::new(),
7380 },
7381 mounts: None,
7382 state: None,
7383 });
7384 }
7385 if id.starts_with("cli_") {
7386 return Ok(DockerInspect {
7387 id: "sha256:610e6cfca95280188b021774f8cf69dd6f49bdb6eebc34c5ee2010f4d51cc105"
7388 .to_string(),
7389 config: DockerInspectConfig {
7390 labels: DockerConfigLabels {
7391 metadata: Some(vec![HashMap::from([(
7392 "remoteUser".to_string(),
7393 Value::String("node".to_string()),
7394 )])]),
7395 },
7396 image_user: Some("root".to_string()),
7397 env: vec!["PATH=/initial/path".to_string()],
7398 },
7399 mounts: None,
7400 state: None,
7401 });
7402 }
7403 if id == "found_docker_ps" {
7404 return Ok(DockerInspect {
7405 id: "sha256:610e6cfca95280188b021774f8cf69dd6f49bdb6eebc34c5ee2010f4d51cc105"
7406 .to_string(),
7407 config: DockerInspectConfig {
7408 labels: DockerConfigLabels {
7409 metadata: Some(vec![HashMap::from([(
7410 "remoteUser".to_string(),
7411 Value::String("node".to_string()),
7412 )])]),
7413 },
7414 image_user: Some("root".to_string()),
7415 env: vec!["PATH=/initial/path".to_string()],
7416 },
7417 mounts: Some(vec![DockerInspectMount {
7418 source: "/path/to/local/project".to_string(),
7419 destination: "/workspaces/project".to_string(),
7420 }]),
7421 state: None,
7422 });
7423 }
7424 if id.starts_with("rust_a-") {
7425 return Ok(DockerInspect {
7426 id: "sha256:9da65c34ab809e763b13d238fd7a0f129fcabd533627d340f293308cb63620a0"
7427 .to_string(),
7428 config: DockerInspectConfig {
7429 labels: DockerConfigLabels {
7430 metadata: Some(vec![
7431 HashMap::from([(
7432 "remoteUser".to_string(),
7433 Value::String("vscode".to_string()),
7434 )]),
7435 HashMap::from([
7436 (
7437 "capAdd".to_string(),
7438 Value::Array(vec![Value::String("SYS_PTRACE".to_string())]),
7439 ),
7440 (
7441 "securityOpt".to_string(),
7442 Value::Array(vec![Value::String(
7443 "seccomp=unconfined".to_string(),
7444 )]),
7445 ),
7446 ]),
7447 ]),
7448 },
7449 image_user: Some("root".to_string()),
7450 env: Vec::new(),
7451 },
7452 mounts: None,
7453 state: None,
7454 });
7455 }
7456 if id == "test_image:latest" {
7457 return Ok(DockerInspect {
7458 id: "sha256:610e6cfca95280188b021774f8cf69dd6f49bdb6eebc34c5ee2010f4d51cc104"
7459 .to_string(),
7460 config: DockerInspectConfig {
7461 labels: DockerConfigLabels {
7462 metadata: Some(vec![HashMap::from([(
7463 "remoteUser".to_string(),
7464 Value::String("node".to_string()),
7465 )])]),
7466 },
7467 env: Vec::new(),
7468 image_user: Some("root".to_string()),
7469 },
7470 mounts: None,
7471 state: None,
7472 });
7473 }
7474
7475 Err(DevContainerError::DockerNotAvailable)
7476 }
7477 async fn get_docker_compose_config(
7478 &self,
7479 config_files: &Vec<PathBuf>,
7480 ) -> Result<Option<DockerComposeConfig>, DevContainerError> {
7481 let project_path = PathBuf::from(TEST_PROJECT_PATH);
7482 if config_files.len() == 1
7483 && config_files.get(0)
7484 == Some(
7485 &project_path
7486 .join(".devcontainer")
7487 .join("docker-compose.yml"),
7488 )
7489 {
7490 return Ok(Some(DockerComposeConfig {
7491 name: None,
7492 services: HashMap::from([
7493 (
7494 "devcontainer".to_string(),
7495 DockerComposeService {
7496 image: Some("test_image:latest".to_string()),
7497 volumes: vec![MountDefinition {
7498 source: Some("../..".to_string()),
7499 target: "/workspaces".to_string(),
7500 mount_type: Some("bind".to_string()),
7501 }],
7502 command: vec!["sleep".to_string(), "infinity".to_string()],
7503 ..Default::default()
7504 },
7505 ),
7506 (
7507 "app".to_string(),
7508 DockerComposeService {
7509 build: Some(DockerComposeServiceBuild {
7510 context: Some(".".to_string()),
7511 dockerfile: Some("Dockerfile".to_string()),
7512 args: None,
7513 additional_contexts: None,
7514 target: None,
7515 }),
7516 volumes: vec![MountDefinition {
7517 source: Some("../..".to_string()),
7518 target: "/workspaces".to_string(),
7519 mount_type: Some("bind".to_string()),
7520 }],
7521 network_mode: Some("service:db".to_string()),
7522 ..Default::default()
7523 },
7524 ),
7525 (
7526 "db".to_string(),
7527 DockerComposeService {
7528 image: Some("postgres:14.1".to_string()),
7529 volumes: vec![MountDefinition {
7530 source: Some("postgres-data".to_string()),
7531 target: "/var/lib/postgresql/data".to_string(),
7532 mount_type: Some("volume".to_string()),
7533 }],
7534 env_file: Some(vec![".env".to_string()]),
7535 ..Default::default()
7536 },
7537 ),
7538 ]),
7539 volumes: HashMap::from([(
7540 "postgres-data".to_string(),
7541 DockerComposeVolume::default(),
7542 )]),
7543 }));
7544 }
7545 if config_files.len() == 1
7546 && config_files.get(0)
7547 == Some(
7548 &project_path
7549 .join(".devcontainer")
7550 .join("docker-compose-context-parent.yml"),
7551 )
7552 {
7553 return Ok(Some(DockerComposeConfig {
7554 name: None,
7555 services: HashMap::from([(
7556 "app".to_string(),
7557 DockerComposeService {
7558 build: Some(DockerComposeServiceBuild {
7559 context: Some("..".to_string()),
7560 dockerfile: Some(
7561 PathBuf::from(".devcontainer")
7562 .join("Dockerfile")
7563 .display()
7564 .to_string(),
7565 ),
7566 args: None,
7567 additional_contexts: None,
7568 target: None,
7569 }),
7570 ..Default::default()
7571 },
7572 )]),
7573 volumes: HashMap::new(),
7574 }));
7575 }
7576 if config_files.len() == 1
7577 && config_files.get(0)
7578 == Some(
7579 &project_path
7580 .join(".devcontainer")
7581 .join("docker-compose-with-args.yml"),
7582 )
7583 {
7584 return Ok(Some(DockerComposeConfig {
7585 name: None,
7586 services: HashMap::from([(
7587 "app".to_string(),
7588 DockerComposeService {
7589 build: Some(DockerComposeServiceBuild {
7590 context: Some(".".to_string()),
7591 dockerfile: Some("Dockerfile".to_string()),
7592 args: Some(HashMap::from([(
7593 "BASE_IMAGE".to_string(),
7594 "test_image:latest".to_string(),
7595 )])),
7596 additional_contexts: None,
7597 target: None,
7598 }),
7599 ..Default::default()
7600 },
7601 )]),
7602 ..Default::default()
7603 }));
7604 }
7605 if config_files.len() == 1
7606 && config_files.get(0)
7607 == Some(
7608 &project_path
7609 .join(".devcontainer")
7610 .join("docker-compose-plain.yml"),
7611 )
7612 {
7613 return Ok(Some(DockerComposeConfig {
7614 name: None,
7615 services: HashMap::from([(
7616 "app".to_string(),
7617 DockerComposeService {
7618 image: Some("test_image:latest".to_string()),
7619 command: vec!["sleep".to_string(), "infinity".to_string()],
7620 ..Default::default()
7621 },
7622 )]),
7623 ..Default::default()
7624 }));
7625 }
7626 Err(DevContainerError::DockerNotAvailable)
7627 }
7628 async fn docker_compose_build(
7629 &self,
7630 _config_files: &Vec<PathBuf>,
7631 _project_name: &str,
7632 _services: Option<&Vec<String>>,
7633 ) -> Result<(), DevContainerError> {
7634 self.compose_build_services
7635 .lock()
7636 .expect("should be available")
7637 .push(_services.cloned());
7638 Ok(())
7639 }
7640 async fn run_docker_exec(
7641 &self,
7642 container_id: &str,
7643 remote_folder: &str,
7644 user: &str,
7645 env: &HashMap<String, String>,
7646 inner_command: Command,
7647 ) -> Result<(), DevContainerError> {
7648 let mut record = self
7649 .exec_commands_recorded
7650 .lock()
7651 .expect("should be available");
7652 record.push(RecordedExecCommand {
7653 _container_id: container_id.to_string(),
7654 _remote_folder: remote_folder.to_string(),
7655 _user: user.to_string(),
7656 env: env.clone(),
7657 _inner_command: inner_command,
7658 });
7659 Ok(())
7660 }
7661 async fn start_container(&self, _id: &str) -> Result<(), DevContainerError> {
7662 Err(DevContainerError::DockerNotAvailable)
7663 }
7664 async fn find_process_by_filters(
7665 &self,
7666 _filters: Vec<String>,
7667 ) -> Result<Option<DockerPs>, DevContainerError> {
7668 if let Some(ids) = self
7669 .duplicate_container_ids
7670 .lock()
7671 .expect("should be available")
7672 .clone()
7673 {
7674 return Err(DevContainerError::MultipleMatchingContainers(ids));
7675 }
7676 Ok(Some(DockerPs {
7677 id: "found_docker_ps".to_string(),
7678 }))
7679 }
7680 fn supports_compose_buildkit(&self) -> bool {
7681 !self.podman && self.has_buildx
7682 }
7683 fn docker_cli(&self) -> String {
7684 if self.podman {
7685 "podman".to_string()
7686 } else {
7687 "docker".to_string()
7688 }
7689 }
7690 }
7691
7692 #[derive(Debug, Clone)]
7693 pub(crate) struct TestCommand {
7694 pub(crate) program: String,
7695 pub(crate) args: Vec<String>,
7696 }
7697
7698 pub(crate) struct TestCommandRunner {
7699 commands_recorded: Mutex<Vec<TestCommand>>,
7700 }
7701
7702 impl TestCommandRunner {
7703 fn new() -> Self {
7704 Self {
7705 commands_recorded: Mutex::new(Vec::new()),
7706 }
7707 }
7708
7709 fn commands_by_program(&self, program: &str) -> Vec<TestCommand> {
7710 let record = self.commands_recorded.lock().expect("poisoned");
7711 record
7712 .iter()
7713 .filter(|r| r.program == program)
7714 .map(|r| r.clone())
7715 .collect()
7716 }
7717 }
7718
7719 #[async_trait]
7720 impl CommandRunner for TestCommandRunner {
7721 async fn run_command(&self, command: &mut Command) -> Result<Output, std::io::Error> {
7722 let mut record = self.commands_recorded.lock().expect("poisoned");
7723
7724 record.push(TestCommand {
7725 program: command.get_program().display().to_string(),
7726 args: command
7727 .get_args()
7728 .map(|a| a.display().to_string())
7729 .collect(),
7730 });
7731
7732 Ok(Output {
7733 status: ExitStatus::default(),
7734 stdout: vec![],
7735 stderr: vec![],
7736 })
7737 }
7738 }
7739
7740 fn fake_http_client() -> Arc<dyn HttpClient> {
7741 FakeHttpClient::create(|request| async move {
7742 let (parts, _body) = request.into_parts();
7743 if parts.uri.path() == "/token" {
7744 let token_response = TokenResponse {
7745 token: "token".to_string(),
7746 };
7747 return Ok(http::Response::builder()
7748 .status(200)
7749 .body(http_client::AsyncBody::from(
7750 serde_json_lenient::to_string(&token_response).unwrap(),
7751 ))
7752 .unwrap());
7753 }
7754
7755 // OCI specific things
7756 if parts.uri.path() == "/v2/devcontainers/features/docker-in-docker/manifests/2" {
7757 let response = r#"
7758 {
7759 "schemaVersion": 2,
7760 "mediaType": "application/vnd.oci.image.manifest.v1+json",
7761 "config": {
7762 "mediaType": "application/vnd.devcontainers",
7763 "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
7764 "size": 2
7765 },
7766 "layers": [
7767 {
7768 "mediaType": "application/vnd.devcontainers.layer.v1+tar",
7769 "digest": "sha256:bc7ab0d8d8339416e1491419ab9ffe931458d0130110f4b18351b0fa184e67d5",
7770 "size": 59392,
7771 "annotations": {
7772 "org.opencontainers.image.title": "devcontainer-feature-docker-in-docker.tgz"
7773 }
7774 }
7775 ],
7776 "annotations": {
7777 "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\"]}",
7778 "com.github.package.type": "devcontainer_feature"
7779 }
7780 }
7781 "#;
7782 return Ok(http::Response::builder()
7783 .status(200)
7784 .body(http_client::AsyncBody::from(response))
7785 .unwrap());
7786 }
7787
7788 if parts.uri.path()
7789 == "/v2/devcontainers/features/docker-in-docker/blobs/sha256:bc7ab0d8d8339416e1491419ab9ffe931458d0130110f4b18351b0fa184e67d5"
7790 {
7791 let response = build_tarball(vec that is part of the [Moby project](https://mobyproject.org/). With this in mind:
7796 * 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.
7797 * 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:
7798 ```
7799 FROM --platform=linux/amd64 mcr.microsoft.com/devcontainers/typescript-node:16
7800 ```
7801 See [Issue #219](https://github.com/devcontainers/features/issues/219) for more details.
7802
7803
7804 ## OS Support
7805
7806 This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed.
7807
7808 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).
7809
7810 `bash` is required to execute the `install.sh` script."#),
7811 ("./README.md", r#"
7812 # Docker (Docker-in-Docker) (docker-in-docker)
7813
7814 Create child containers *inside* a container, independent from the host's docker instance. Installs Docker extension in the container along with needed CLIs.
7815
7816 ## Example Usage
7817
7818 ```json
7819 "features": {
7820 "ghcr.io/devcontainers/features/docker-in-docker:2": {}
7821 }
7822 ```
7823
7824 ## Options
7825
7826 | Options Id | Description | Type | Default Value |
7827 |-----|-----|-----|-----|
7828 | version | Select or enter a Docker/Moby Engine version. (Availability can vary by OS version.) | string | latest |
7829 | moby | Install OSS Moby build instead of Docker CE | boolean | true |
7830 | mobyBuildxVersion | Install a specific version of moby-buildx when using Moby | string | latest |
7831 | dockerDashComposeVersion | Default version of Docker Compose (v1, v2 or none) | string | v2 |
7832 | azureDnsAutoDetection | Allow automatically setting the dockerd DNS server when the installation script detects it is running in Azure | boolean | true |
7833 | dockerDefaultAddressPool | Define default address pools for Docker networks. e.g. base=192.168.0.0/16,size=24 | string | - |
7834 | installDockerBuildx | Install Docker Buildx | boolean | true |
7835 | 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 |
7836 | disableIp6tables | Disable ip6tables (this option is only applicable for Docker versions 27 and greater) | boolean | false |
7837
7838 ## Customizations
7839
7840 ### VS Code Extensions
7841
7842 - `ms-azuretools.vscode-containers`
7843
7844 ## Limitations
7845
7846 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:
7847 * 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.
7848 * 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:
7849 ```
7850 FROM --platform=linux/amd64 mcr.microsoft.com/devcontainers/typescript-node:16
7851 ```
7852 See [Issue #219](https://github.com/devcontainers/features/issues/219) for more details.
7853
7854
7855 ## OS Support
7856
7857 This Feature should work on recent versions of Debian/Ubuntu-based distributions with the `apt` package manager installed.
7858
7859 `bash` is required to execute the `install.sh` script.
7860
7861
7862 ---
7863
7864 _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`._"#),
7865 ("./devcontainer-feature.json", r#"
7866 {
7867 "id": "docker-in-docker",
7868 "version": "2.16.1",
7869 "name": "Docker (Docker-in-Docker)",
7870 "documentationURL": "https://github.com/devcontainers/features/tree/main/src/docker-in-docker",
7871 "description": "Create child containers *inside* a container, independent from the host's docker instance. Installs Docker extension in the container along with needed CLIs.",
7872 "options": {
7873 "version": {
7874 "type": "string",
7875 "proposals": [
7876 "latest",
7877 "none",
7878 "20.10"
7879 ],
7880 "default": "latest",
7881 "description": "Select or enter a Docker/Moby Engine version. (Availability can vary by OS version.)"
7882 },
7883 "moby": {
7884 "type": "boolean",
7885 "default": true,
7886 "description": "Install OSS Moby build instead of Docker CE"
7887 },
7888 "mobyBuildxVersion": {
7889 "type": "string",
7890 "default": "latest",
7891 "description": "Install a specific version of moby-buildx when using Moby"
7892 },
7893 "dockerDashComposeVersion": {
7894 "type": "string",
7895 "enum": [
7896 "none",
7897 "v1",
7898 "v2"
7899 ],
7900 "default": "v2",
7901 "description": "Default version of Docker Compose (v1, v2 or none)"
7902 },
7903 "azureDnsAutoDetection": {
7904 "type": "boolean",
7905 "default": true,
7906 "description": "Allow automatically setting the dockerd DNS server when the installation script detects it is running in Azure"
7907 },
7908 "dockerDefaultAddressPool": {
7909 "type": "string",
7910 "default": "",
7911 "proposals": [],
7912 "description": "Define default address pools for Docker networks. e.g. base=192.168.0.0/16,size=24"
7913 },
7914 "installDockerBuildx": {
7915 "type": "boolean",
7916 "default": true,
7917 "description": "Install Docker Buildx"
7918 },
7919 "installDockerComposeSwitch": {
7920 "type": "boolean",
7921 "default": false,
7922 "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."
7923 },
7924 "disableIp6tables": {
7925 "type": "boolean",
7926 "default": false,
7927 "description": "Disable ip6tables (this option is only applicable for Docker versions 27 and greater)"
7928 }
7929 },
7930 "entrypoint": "/usr/local/share/docker-init.sh",
7931 "privileged": true,
7932 "containerEnv": {
7933 "DOCKER_BUILDKIT": "1"
7934 },
7935 "customizations": {
7936 "vscode": {
7937 "extensions": [
7938 "ms-azuretools.vscode-containers"
7939 ],
7940 "settings": {
7941 "github.copilot.chat.codeGeneration.instructions": [
7942 {
7943 "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."
7944 }
7945 ]
7946 }
7947 }
7948 },
7949 "mounts": [
7950 {
7951 "source": "dind-var-lib-docker-${devcontainerId}",
7952 "target": "/var/lib/docker",
7953 "type": "volume"
7954 }
7955 ],
7956 "installsAfter": [
7957 "ghcr.io/devcontainers/features/common-utils"
7958 ]
7959 }"#),
7960 ("./install.sh", r#"
7961 #!/usr/bin/env bash
7962 #-------------------------------------------------------------------------------------------------------------
7963 # Copyright (c) Microsoft Corporation. All rights reserved.
7964 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
7965 #-------------------------------------------------------------------------------------------------------------
7966 #
7967 # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/docker-in-docker.md
7968 # Maintainer: The Dev Container spec maintainers
7969
7970
7971 DOCKER_VERSION="${VERSION:-"latest"}" # The Docker/Moby Engine + CLI should match in version
7972 USE_MOBY="${MOBY:-"true"}"
7973 MOBY_BUILDX_VERSION="${MOBYBUILDXVERSION:-"latest"}"
7974 DOCKER_DASH_COMPOSE_VERSION="${DOCKERDASHCOMPOSEVERSION:-"v2"}" #v1, v2 or none
7975 AZURE_DNS_AUTO_DETECTION="${AZUREDNSAUTODETECTION:-"true"}"
7976 DOCKER_DEFAULT_ADDRESS_POOL="${DOCKERDEFAULTADDRESSPOOL:-""}"
7977 USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}"
7978 INSTALL_DOCKER_BUILDX="${INSTALLDOCKERBUILDX:-"true"}"
7979 INSTALL_DOCKER_COMPOSE_SWITCH="${INSTALLDOCKERCOMPOSESWITCH:-"false"}"
7980 MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc"
7981 MICROSOFT_GPG_KEYS_ROLLING_URI="https://packages.microsoft.com/keys/microsoft-rolling.asc"
7982 DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES="trixie bookworm buster bullseye bionic focal jammy noble"
7983 DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES="trixie bookworm buster bullseye bionic focal hirsute impish jammy noble"
7984 DISABLE_IP6_TABLES="${DISABLEIP6TABLES:-false}"
7985
7986 # Default: Exit on any failure.
7987 set -e
7988
7989 # Clean up
7990 rm -rf /var/lib/apt/lists/*
7991
7992 # Setup STDERR.
7993 err() {
7994 echo "(!) $*" >&2
7995 }
7996
7997 if [ "$(id -u)" -ne 0 ]; then
7998 err 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
7999 exit 1
8000 fi
8001
8002 ###################
8003 # Helper Functions
8004 # See: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/shared/utils.sh
8005 ###################
8006
8007 # Determine the appropriate non-root user
8008 if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
8009 USERNAME=""
8010 POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
8011 for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do
8012 if id -u ${CURRENT_USER} > /dev/null 2>&1; then
8013 USERNAME=${CURRENT_USER}
8014 break
8015 fi
8016 done
8017 if [ "${USERNAME}" = "" ]; then
8018 USERNAME=root
8019 fi
8020 elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then
8021 USERNAME=root
8022 fi
8023
8024 # Package manager update function
8025 pkg_mgr_update() {
8026 case ${ADJUSTED_ID} in
8027 debian)
8028 if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then
8029 echo "Running apt-get update..."
8030 apt-get update -y
8031 fi
8032 ;;
8033 rhel)
8034 if [ ${PKG_MGR_CMD} = "microdnf" ]; then
8035 cache_check_dir="/var/cache/yum"
8036 else
8037 cache_check_dir="/var/cache/${PKG_MGR_CMD}"
8038 fi
8039 if [ "$(ls ${cache_check_dir}/* 2>/dev/null | wc -l)" = 0 ]; then
8040 echo "Running ${PKG_MGR_CMD} makecache ..."
8041 ${PKG_MGR_CMD} makecache
8042 fi
8043 ;;
8044 esac
8045 }
8046
8047 # Checks if packages are installed and installs them if not
8048 check_packages() {
8049 case ${ADJUSTED_ID} in
8050 debian)
8051 if ! dpkg -s "$@" > /dev/null 2>&1; then
8052 pkg_mgr_update
8053 apt-get -y install --no-install-recommends "$@"
8054 fi
8055 ;;
8056 rhel)
8057 if ! rpm -q "$@" > /dev/null 2>&1; then
8058 pkg_mgr_update
8059 ${PKG_MGR_CMD} -y install "$@"
8060 fi
8061 ;;
8062 esac
8063 }
8064
8065 # Figure out correct version of a three part version number is not passed
8066 find_version_from_git_tags() {
8067 local variable_name=$1
8068 local requested_version=${!variable_name}
8069 if [ "${requested_version}" = "none" ]; then return; fi
8070 local repository=$2
8071 local prefix=${3:-"tags/v"}
8072 local separator=${4:-"."}
8073 local last_part_optional=${5:-"false"}
8074 if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then
8075 local escaped_separator=${separator//./\\.}
8076 local last_part
8077 if [ "${last_part_optional}" = "true" ]; then
8078 last_part="(${escaped_separator}[0-9]+)?"
8079 else
8080 last_part="${escaped_separator}[0-9]+"
8081 fi
8082 local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$"
8083 local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)"
8084 if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then
8085 declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)"
8086 else
8087 set +e
8088 declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")"
8089 set -e
8090 fi
8091 fi
8092 if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then
8093 err "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2
8094 exit 1
8095 fi
8096 echo "${variable_name}=${!variable_name}"
8097 }
8098
8099 # Use semver logic to decrement a version number then look for the closest match
8100 find_prev_version_from_git_tags() {
8101 local variable_name=$1
8102 local current_version=${!variable_name}
8103 local repository=$2
8104 # Normally a "v" is used before the version number, but support alternate cases
8105 local prefix=${3:-"tags/v"}
8106 # Some repositories use "_" instead of "." for version number part separation, support that
8107 local separator=${4:-"."}
8108 # Some tools release versions that omit the last digit (e.g. go)
8109 local last_part_optional=${5:-"false"}
8110 # Some repositories may have tags that include a suffix (e.g. actions/node-versions)
8111 local version_suffix_regex=$6
8112 # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios.
8113 set +e
8114 major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')"
8115 minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')"
8116 breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')"
8117
8118 if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then
8119 ((major=major-1))
8120 declare -g ${variable_name}="${major}"
8121 # Look for latest version from previous major release
8122 find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}"
8123 # Handle situations like Go's odd version pattern where "0" releases omit the last part
8124 elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then
8125 ((minor=minor-1))
8126 declare -g ${variable_name}="${major}.${minor}"
8127 # Look for latest version from previous minor release
8128 find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}"
8129 else
8130 ((breakfix=breakfix-1))
8131 if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then
8132 declare -g ${variable_name}="${major}.${minor}"
8133 else
8134 declare -g ${variable_name}="${major}.${minor}.${breakfix}"
8135 fi
8136 fi
8137 set -e
8138 }
8139
8140 # Function to fetch the version released prior to the latest version
8141 get_previous_version() {
8142 local url=$1
8143 local repo_url=$2
8144 local variable_name=$3
8145 prev_version=${!variable_name}
8146
8147 output=$(curl -s "$repo_url");
8148 if echo "$output" | jq -e 'type == "object"' > /dev/null; then
8149 message=$(echo "$output" | jq -r '.message')
8150
8151 if [[ $message == "API rate limit exceeded"* ]]; then
8152 echo -e "\nAn attempt to find latest version using GitHub Api Failed... \nReason: ${message}"
8153 echo -e "\nAttempting to find latest version using GitHub tags."
8154 find_prev_version_from_git_tags prev_version "$url" "tags/v"
8155 declare -g ${variable_name}="${prev_version}"
8156 fi
8157 elif echo "$output" | jq -e 'type == "array"' > /dev/null; then
8158 echo -e "\nAttempting to find latest version using GitHub Api."
8159 version=$(echo "$output" | jq -r '.[1].tag_name')
8160 declare -g ${variable_name}="${version#v}"
8161 fi
8162 echo "${variable_name}=${!variable_name}"
8163 }
8164
8165 get_github_api_repo_url() {
8166 local url=$1
8167 echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases"
8168 }
8169
8170 ###########################################
8171 # Start docker-in-docker installation
8172 ###########################################
8173
8174 # Ensure apt is in non-interactive to avoid prompts
8175 export DEBIAN_FRONTEND=noninteractive
8176
8177 # Source /etc/os-release to get OS info
8178 . /etc/os-release
8179
8180 # Determine adjusted ID and package manager
8181 if [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ]; then
8182 ADJUSTED_ID="debian"
8183 PKG_MGR_CMD="apt-get"
8184 # Use dpkg for Debian-based systems
8185 architecture="$(dpkg --print-architecture 2>/dev/null || uname -m)"
8186 elif [[ "${ID}" = "rhel" || "${ID}" = "fedora" || "${ID}" = "azurelinux" || "${ID}" = "mariner" || "${ID_LIKE}" = *"rhel"* || "${ID_LIKE}" = *"fedora"* || "${ID_LIKE}" = *"azurelinux"* || "${ID_LIKE}" = *"mariner"* ]]; then
8187 ADJUSTED_ID="rhel"
8188 # Determine the appropriate package manager for RHEL-based systems
8189 for pkg_mgr in tdnf dnf microdnf yum; do
8190 if command -v "$pkg_mgr" >/dev/null 2>&1; then
8191 PKG_MGR_CMD="$pkg_mgr"
8192 break
8193 fi
8194 done
8195
8196 if [ -z "${PKG_MGR_CMD}" ]; then
8197 err "Unable to find a supported package manager (tdnf, dnf, microdnf, yum)"
8198 exit 1
8199 fi
8200
8201 architecture="$(rpm --eval '%{_arch}' 2>/dev/null || uname -m)"
8202 else
8203 err "Linux distro ${ID} not supported."
8204 exit 1
8205 fi
8206
8207 # Azure Linux specific setup
8208 if [ "${ID}" = "azurelinux" ]; then
8209 VERSION_CODENAME="azurelinux${VERSION_ID}"
8210 fi
8211
8212 # Prevent attempting to install Moby on Debian trixie (packages removed)
8213 if [ "${USE_MOBY}" = "true" ] && [ "${ID}" = "debian" ] && [ "${VERSION_CODENAME}" = "trixie" ]; then
8214 err "The 'moby' option is not supported on Debian 'trixie' because 'moby-cli' and related system packages have been removed from that distribution."
8215 err "To continue, either set the feature option '\"moby\": false' or use a different base image (for example: 'debian:bookworm' or 'ubuntu-24.04')."
8216 exit 1
8217 fi
8218
8219 # Check if distro is supported
8220 if [ "${USE_MOBY}" = "true" ]; then
8221 if [ "${ADJUSTED_ID}" = "debian" ]; then
8222 if [[ "${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then
8223 err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS distribution"
8224 err "Supported distributions include: ${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}"
8225 exit 1
8226 fi
8227 echo "(*) ${VERSION_CODENAME} is supported for Moby installation - setting up Microsoft repository"
8228 elif [ "${ADJUSTED_ID}" = "rhel" ]; then
8229 if [ "${ID}" = "azurelinux" ] || [ "${ID}" = "mariner" ]; then
8230 echo " (*) ${ID} ${VERSION_ID} detected - using Microsoft repositories for Moby packages"
8231 else
8232 echo "RHEL-based system (${ID}) detected - Moby packages may require additional configuration"
8233 fi
8234 fi
8235 else
8236 if [ "${ADJUSTED_ID}" = "debian" ]; then
8237 if [[ "${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then
8238 err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, please choose a compatible OS distribution"
8239 err "Supported distributions include: ${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}"
8240 exit 1
8241 fi
8242 echo "(*) ${VERSION_CODENAME} is supported for Docker CE installation (supported: ${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}) - setting up Docker repository"
8243 elif [ "${ADJUSTED_ID}" = "rhel" ]; then
8244
8245 echo "RHEL-based system (${ID}) detected - using Docker CE packages"
8246 fi
8247 fi
8248
8249 # Install base dependencies
8250 base_packages="curl ca-certificates pigz iptables gnupg2 wget jq"
8251 case ${ADJUSTED_ID} in
8252 debian)
8253 check_packages apt-transport-https $base_packages dirmngr
8254 ;;
8255 rhel)
8256 check_packages $base_packages tar gawk shadow-utils policycoreutils procps-ng systemd-libs systemd-devel
8257
8258 ;;
8259 esac
8260
8261 # Install git if not already present
8262 if ! command -v git >/dev/null 2>&1; then
8263 check_packages git
8264 fi
8265
8266 # Update CA certificates to ensure HTTPS connections work properly
8267 # This is especially important for Ubuntu 24.04 (Noble) and Debian Trixie
8268 # Only run for Debian-based systems (RHEL uses update-ca-trust instead)
8269 if [ "${ADJUSTED_ID}" = "debian" ] && command -v update-ca-certificates > /dev/null 2>&1; then
8270 update-ca-certificates
8271 fi
8272
8273 # Swap to legacy iptables for compatibility (Debian only)
8274 if [ "${ADJUSTED_ID}" = "debian" ] && type iptables-legacy > /dev/null 2>&1; then
8275 update-alternatives --set iptables /usr/sbin/iptables-legacy
8276 update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy
8277 fi
8278
8279 # Set up the necessary repositories
8280 if [ "${USE_MOBY}" = "true" ]; then
8281 # Name of open source engine/cli
8282 engine_package_name="moby-engine"
8283 cli_package_name="moby-cli"
8284
8285 case ${ADJUSTED_ID} in
8286 debian)
8287 # Import key safely and import Microsoft apt repo
8288 {
8289 curl -sSL ${MICROSOFT_GPG_KEYS_URI}
8290 curl -sSL ${MICROSOFT_GPG_KEYS_ROLLING_URI}
8291 } | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg
8292 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
8293 ;;
8294 rhel)
8295 echo "(*) ${ID} detected - checking for Moby packages..."
8296
8297 # Check if moby packages are available in default repos
8298 if ${PKG_MGR_CMD} list available moby-engine >/dev/null 2>&1; then
8299 echo "(*) Using built-in ${ID} Moby packages"
8300 else
8301 case "${ID}" in
8302 azurelinux)
8303 echo "(*) Moby packages not found in Azure Linux repositories"
8304 echo "(*) For Azure Linux, Docker CE ('moby': false) is recommended"
8305 err "Moby packages are not available for Azure Linux ${VERSION_ID}."
8306 err "Recommendation: Use '\"moby\": false' to install Docker CE instead."
8307 exit 1
8308 ;;
8309 mariner)
8310 echo "(*) Adding Microsoft repository for CBL-Mariner..."
8311 # Add Microsoft repository if packages aren't available locally
8312 curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /etc/pki/rpm-gpg/microsoft.gpg
8313 cat > /etc/yum.repos.d/microsoft.repo << EOF
8314 [microsoft]
8315 name=Microsoft Repository
8316 baseurl=https://packages.microsoft.com/repos/microsoft-cbl-mariner-2.0-prod-base/
8317 enabled=1
8318 gpgcheck=1
8319 gpgkey=file:///etc/pki/rpm-gpg/microsoft.gpg
8320 EOF
8321 # Verify packages are available after adding repo
8322 pkg_mgr_update
8323 if ! ${PKG_MGR_CMD} list available moby-engine >/dev/null 2>&1; then
8324 echo "(*) Moby packages not found in Microsoft repository either"
8325 err "Moby packages are not available for CBL-Mariner ${VERSION_ID}."
8326 err "Recommendation: Use '\"moby\": false' to install Docker CE instead."
8327 exit 1
8328 fi
8329 ;;
8330 *)
8331 err "Moby packages are not available for ${ID}. Please use 'moby': false option."
8332 exit 1
8333 ;;
8334 esac
8335 fi
8336 ;;
8337 esac
8338 else
8339 # Name of licensed engine/cli
8340 engine_package_name="docker-ce"
8341 cli_package_name="docker-ce-cli"
8342 case ${ADJUSTED_ID} in
8343 debian)
8344 curl -fsSL https://download.docker.com/linux/${ID}/gpg | gpg --dearmor > /usr/share/keyrings/docker-archive-keyring.gpg
8345 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
8346 ;;
8347 rhel)
8348 # Docker CE repository setup for RHEL-based systems
8349 setup_docker_ce_repo() {
8350 curl -fsSL https://download.docker.com/linux/centos/gpg > /etc/pki/rpm-gpg/docker-ce.gpg
8351 cat > /etc/yum.repos.d/docker-ce.repo << EOF
8352 [docker-ce-stable]
8353 name=Docker CE Stable
8354 baseurl=https://download.docker.com/linux/centos/9/\$basearch/stable
8355 enabled=1
8356 gpgcheck=1
8357 gpgkey=file:///etc/pki/rpm-gpg/docker-ce.gpg
8358 skip_if_unavailable=1
8359 module_hotfixes=1
8360 EOF
8361 }
8362 install_azure_linux_deps() {
8363 echo "(*) Installing device-mapper libraries for Docker CE..."
8364 [ "${ID}" != "mariner" ] && ${PKG_MGR_CMD} -y install device-mapper-libs 2>/dev/null || echo "(*) Device-mapper install failed, proceeding"
8365 echo "(*) Installing additional Docker CE dependencies..."
8366 ${PKG_MGR_CMD} -y install libseccomp libtool-ltdl systemd-libs libcgroup tar xz || {
8367 echo "(*) Some optional dependencies could not be installed, continuing..."
8368 }
8369 }
8370 setup_selinux_context() {
8371 if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null)" != "Disabled" ]; then
8372 echo "(*) Creating minimal SELinux context for Docker compatibility..."
8373 mkdir -p /etc/selinux/targeted/contexts/files/ 2>/dev/null || true
8374 echo "/var/lib/docker(/.*)? system_u:object_r:container_file_t:s0" >> /etc/selinux/targeted/contexts/files/file_contexts.local 2>/dev/null || true
8375 fi
8376 }
8377
8378 # Special handling for RHEL Docker CE installation
8379 case "${ID}" in
8380 azurelinux|mariner)
8381 echo "(*) ${ID} detected"
8382 echo "(*) Note: Moby packages work better on Azure Linux. Consider using 'moby': true"
8383 echo "(*) Setting up Docker CE repository..."
8384
8385 setup_docker_ce_repo
8386 install_azure_linux_deps
8387
8388 if [ "${USE_MOBY}" != "true" ]; then
8389 echo "(*) Docker CE installation for Azure Linux - skipping container-selinux"
8390 echo "(*) Note: SELinux policies will be minimal but Docker will function normally"
8391 setup_selinux_context
8392 else
8393 echo "(*) Using Moby - container-selinux not required"
8394 fi
8395 ;;
8396 *)
8397 # Standard RHEL/CentOS/Fedora approach
8398 if command -v dnf >/dev/null 2>&1; then
8399 dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
8400 elif command -v yum-config-manager >/dev/null 2>&1; then
8401 yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
8402 else
8403 # Manual fallback
8404 setup_docker_ce_repo
8405 fi
8406 ;;
8407 esac
8408 ;;
8409 esac
8410 fi
8411
8412 # Refresh package database
8413 case ${ADJUSTED_ID} in
8414 debian)
8415 apt-get update
8416 ;;
8417 rhel)
8418 pkg_mgr_update
8419 ;;
8420 esac
8421
8422 # Soft version matching
8423 if [ "${DOCKER_VERSION}" = "latest" ] || [ "${DOCKER_VERSION}" = "lts" ] || [ "${DOCKER_VERSION}" = "stable" ]; then
8424 # Empty, meaning grab whatever "latest" is in apt repo
8425 engine_version_suffix=""
8426 cli_version_suffix=""
8427 else
8428 case ${ADJUSTED_ID} in
8429 debian)
8430 # Fetch a valid version from the apt-cache (eg: the Microsoft repo appends +azure, breakfix, etc...)
8431 docker_version_dot_escaped="${DOCKER_VERSION//./\\.}"
8432 docker_version_dot_plus_escaped="${docker_version_dot_escaped//+/\\+}"
8433 # Regex needs to handle debian package version number format: https://www.systutorials.com/docs/linux/man/5-deb-version/
8434 docker_version_regex="^(.+:)?${docker_version_dot_plus_escaped}([\\.\\+ ~:-]|$)"
8435 set +e # Don't exit if finding version fails - will handle gracefully
8436 cli_version_suffix="=$(apt-cache madison ${cli_package_name} | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${docker_version_regex}")"
8437 engine_version_suffix="=$(apt-cache madison ${engine_package_name} | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${docker_version_regex}")"
8438 set -e
8439 if [ -z "${engine_version_suffix}" ] || [ "${engine_version_suffix}" = "=" ] || [ -z "${cli_version_suffix}" ] || [ "${cli_version_suffix}" = "=" ] ; then
8440 err "No full or partial Docker / Moby version match found for \"${DOCKER_VERSION}\" on OS ${ID} ${VERSION_CODENAME} (${architecture}). Available versions:"
8441 apt-cache madison ${cli_package_name} | awk -F"|" '{print $2}' | grep -oP '^(.+:)?\K.+'
8442 exit 1
8443 fi
8444 ;;
8445 rhel)
8446 # For RHEL-based systems, use dnf/yum to find versions
8447 docker_version_escaped="${DOCKER_VERSION//./\\.}"
8448 set +e # Don't exit if finding version fails - will handle gracefully
8449 if [ "${USE_MOBY}" = "true" ]; then
8450 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)
8451 else
8452 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)
8453 fi
8454 set -e
8455 if [ -n "${available_versions}" ]; then
8456 engine_version_suffix="-${available_versions}"
8457 cli_version_suffix="-${available_versions}"
8458 else
8459 echo "(*) Exact version ${DOCKER_VERSION} not found, using latest available"
8460 engine_version_suffix=""
8461 cli_version_suffix=""
8462 fi
8463 ;;
8464 esac
8465 fi
8466
8467 # Version matching for moby-buildx
8468 if [ "${USE_MOBY}" = "true" ]; then
8469 if [ "${MOBY_BUILDX_VERSION}" = "latest" ]; then
8470 # Empty, meaning grab whatever "latest" is in apt repo
8471 buildx_version_suffix=""
8472 else
8473 case ${ADJUSTED_ID} in
8474 debian)
8475 buildx_version_dot_escaped="${MOBY_BUILDX_VERSION//./\\.}"
8476 buildx_version_dot_plus_escaped="${buildx_version_dot_escaped//+/\\+}"
8477 buildx_version_regex="^(.+:)?${buildx_version_dot_plus_escaped}([\\.\\+ ~:-]|$)"
8478 set +e
8479 buildx_version_suffix="=$(apt-cache madison moby-buildx | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${buildx_version_regex}")"
8480 set -e
8481 if [ -z "${buildx_version_suffix}" ] || [ "${buildx_version_suffix}" = "=" ]; then
8482 err "No full or partial moby-buildx version match found for \"${MOBY_BUILDX_VERSION}\" on OS ${ID} ${VERSION_CODENAME} (${architecture}). Available versions:"
8483 apt-cache madison moby-buildx | awk -F"|" '{print $2}' | grep -oP '^(.+:)?\K.+'
8484 exit 1
8485 fi
8486 ;;
8487 rhel)
8488 # For RHEL-based systems, try to find buildx version or use latest
8489 buildx_version_escaped="${MOBY_BUILDX_VERSION//./\\.}"
8490 set +e
8491 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)
8492 set -e
8493 if [ -n "${available_buildx}" ]; then
8494 buildx_version_suffix="-${available_buildx}"
8495 else
8496 echo "(*) Exact buildx version ${MOBY_BUILDX_VERSION} not found, using latest available"
8497 buildx_version_suffix=""
8498 fi
8499 ;;
8500 esac
8501 echo "buildx_version_suffix ${buildx_version_suffix}"
8502 fi
8503 fi
8504
8505 # Install Docker / Moby CLI if not already installed
8506 if type docker > /dev/null 2>&1 && type dockerd > /dev/null 2>&1; then
8507 echo "Docker / Moby CLI and Engine already installed."
8508 else
8509 case ${ADJUSTED_ID} in
8510 debian)
8511 if [ "${USE_MOBY}" = "true" ]; then
8512 # Install engine
8513 set +e # Handle error gracefully
8514 apt-get -y install --no-install-recommends moby-cli${cli_version_suffix} moby-buildx${buildx_version_suffix} moby-engine${engine_version_suffix}
8515 exit_code=$?
8516 set -e
8517
8518 if [ ${exit_code} -ne 0 ]; then
8519 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')."
8520 exit 1
8521 fi
8522
8523 # Install compose
8524 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."
8525 else
8526 apt-get -y install --no-install-recommends docker-ce-cli${cli_version_suffix} docker-ce${engine_version_suffix}
8527 # Install compose
8528 apt-mark hold docker-ce docker-ce-cli
8529 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."
8530 fi
8531 ;;
8532 rhel)
8533 if [ "${USE_MOBY}" = "true" ]; then
8534 set +e # Handle error gracefully
8535 ${PKG_MGR_CMD} -y install moby-cli${cli_version_suffix} moby-engine${engine_version_suffix}
8536 exit_code=$?
8537 set -e
8538
8539 if [ ${exit_code} -ne 0 ]; then
8540 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."
8541 exit 1
8542 fi
8543
8544 # Install compose
8545 if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "none" ]; then
8546 ${PKG_MGR_CMD} -y install moby-compose || echo "(*) Package moby-compose not available for ${ID} ${VERSION_CODENAME} (${architecture}). Skipping."
8547 fi
8548 else
8549 # Special handling for Azure Linux Docker CE installation
8550 if [ "${ID}" = "azurelinux" ] || [ "${ID}" = "mariner" ]; then
8551 echo "(*) Installing Docker CE on Azure Linux (bypassing container-selinux dependency)..."
8552
8553 # Use rpm with --force and --nodeps for Azure Linux
8554 set +e # Don't exit on error for this section
8555 ${PKG_MGR_CMD} -y install docker-ce${cli_version_suffix} docker-ce-cli${engine_version_suffix} containerd.io
8556 install_result=$?
8557 set -e
8558
8559 if [ $install_result -ne 0 ]; then
8560 echo "(*) Standard installation failed, trying manual installation..."
8561
8562 echo "(*) Standard installation failed, trying manual installation..."
8563
8564 # Create directory for downloading packages
8565 mkdir -p /tmp/docker-ce-install
8566
8567 # Download packages manually using curl since tdnf doesn't support download
8568 echo "(*) Downloading Docker CE packages manually..."
8569
8570 # Get the repository baseurl
8571 repo_baseurl="https://download.docker.com/linux/centos/9/x86_64/stable"
8572
8573 # Download packages directly
8574 cd /tmp/docker-ce-install
8575
8576 # Get package names with versions
8577 if [ -n "${cli_version_suffix}" ]; then
8578 docker_ce_version="${cli_version_suffix#-}"
8579 docker_cli_version="${engine_version_suffix#-}"
8580 else
8581 # Get latest version from repository
8582 docker_ce_version="latest"
8583 fi
8584
8585 echo "(*) Attempting to download Docker CE packages from repository..."
8586
8587 # Try to download latest packages if specific version fails
8588 if ! curl -fsSL "${repo_baseurl}/Packages/docker-ce-${docker_ce_version}.el9.x86_64.rpm" -o docker-ce.rpm 2>/dev/null; then
8589 # Fallback: try to get latest available version
8590 echo "(*) Specific version not found, trying latest..."
8591 latest_docker=$(curl -s "${repo_baseurl}/Packages/" | grep -o 'docker-ce-[0-9][^"]*\.el9\.x86_64\.rpm' | head -1)
8592 latest_cli=$(curl -s "${repo_baseurl}/Packages/" | grep -o 'docker-ce-cli-[0-9][^"]*\.el9\.x86_64\.rpm' | head -1)
8593 latest_containerd=$(curl -s "${repo_baseurl}/Packages/" | grep -o 'containerd\.io-[0-9][^"]*\.el9\.x86_64\.rpm' | head -1)
8594
8595 if [ -n "${latest_docker}" ]; then
8596 curl -fsSL "${repo_baseurl}/Packages/${latest_docker}" -o docker-ce.rpm
8597 curl -fsSL "${repo_baseurl}/Packages/${latest_cli}" -o docker-ce-cli.rpm
8598 curl -fsSL "${repo_baseurl}/Packages/${latest_containerd}" -o containerd.io.rpm
8599 else
8600 echo "(*) ERROR: Could not find Docker CE packages in repository"
8601 echo "(*) Please check repository configuration or use 'moby': true"
8602 exit 1
8603 fi
8604 fi
8605 # Install systemd libraries required by Docker CE
8606 echo "(*) Installing systemd libraries required by Docker CE..."
8607 ${PKG_MGR_CMD} -y install systemd-libs || ${PKG_MGR_CMD} -y install systemd-devel || {
8608 echo "(*) WARNING: Could not install systemd libraries"
8609 echo "(*) Docker may fail to start without these"
8610 }
8611
8612 # Install with rpm --force --nodeps
8613 echo "(*) Installing Docker CE packages with dependency override..."
8614 rpm -Uvh --force --nodeps *.rpm
8615
8616 # Cleanup
8617 cd /
8618 rm -rf /tmp/docker-ce-install
8619
8620 echo "(*) Docker CE installation completed with dependency bypass"
8621 echo "(*) Note: Some SELinux functionality may be limited without container-selinux"
8622 fi
8623 else
8624 # Standard installation for other RHEL-based systems
8625 ${PKG_MGR_CMD} -y install docker-ce${cli_version_suffix} docker-ce-cli${engine_version_suffix} containerd.io
8626 fi
8627 # Install compose
8628 if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "none" ]; then
8629 ${PKG_MGR_CMD} -y install docker-compose-plugin || echo "(*) Package docker-compose-plugin not available for ${ID} ${VERSION_CODENAME} (${architecture}). Skipping."
8630 fi
8631 fi
8632 ;;
8633 esac
8634 fi
8635
8636 echo "Finished installing docker / moby!"
8637
8638 docker_home="/usr/libexec/docker"
8639 cli_plugins_dir="${docker_home}/cli-plugins"
8640
8641 # fallback for docker-compose
8642 fallback_compose(){
8643 local url=$1
8644 local repo_url=$(get_github_api_repo_url "$url")
8645 echo -e "\n(!) Failed to fetch the latest artifacts for docker-compose v${compose_version}..."
8646 get_previous_version "${url}" "${repo_url}" compose_version
8647 echo -e "\nAttempting to install v${compose_version}"
8648 curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path}
8649 }
8650
8651 # If 'docker-compose' command is to be included
8652 if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "none" ]; then
8653 case "${architecture}" in
8654 amd64|x86_64) target_compose_arch=x86_64 ;;
8655 arm64|aarch64) target_compose_arch=aarch64 ;;
8656 *)
8657 echo "(!) Docker in docker does not support machine architecture '$architecture'. Please use an x86-64 or ARM64 machine."
8658 exit 1
8659 esac
8660
8661 docker_compose_path="/usr/local/bin/docker-compose"
8662 if [ "${DOCKER_DASH_COMPOSE_VERSION}" = "v1" ]; then
8663 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."
8664 INSTALL_DOCKER_COMPOSE_SWITCH="false"
8665
8666 if [ "${target_compose_arch}" = "x86_64" ]; then
8667 echo "(*) Installing docker compose v1..."
8668 curl -fsSL "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-Linux-x86_64" -o ${docker_compose_path}
8669 chmod +x ${docker_compose_path}
8670
8671 # Download the SHA256 checksum
8672 DOCKER_COMPOSE_SHA256="$(curl -sSL "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-Linux-x86_64.sha256" | awk '{print $1}')"
8673 echo "${DOCKER_COMPOSE_SHA256} ${docker_compose_path}" > docker-compose.sha256sum
8674 sha256sum -c docker-compose.sha256sum --ignore-missing
8675 elif [ "${VERSION_CODENAME}" = "bookworm" ]; then
8676 err "Docker compose v1 is unavailable for 'bookworm' on Arm64. Kindly switch to use v2"
8677 exit 1
8678 else
8679 # Use pip to get a version that runs on this architecture
8680 check_packages python3-minimal python3-pip libffi-dev python3-venv
8681 echo "(*) Installing docker compose v1 via pip..."
8682 export PYTHONUSERBASE=/usr/local
8683 pip3 install --disable-pip-version-check --no-cache-dir --user "Cython<3.0" pyyaml wheel docker-compose --no-build-isolation
8684 fi
8685 else
8686 compose_version=${DOCKER_DASH_COMPOSE_VERSION#v}
8687 docker_compose_url="https://github.com/docker/compose"
8688 find_version_from_git_tags compose_version "$docker_compose_url" "tags/v"
8689 echo "(*) Installing docker-compose ${compose_version}..."
8690 curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path} || {
8691 echo -e "\n(!) Failed to fetch the latest artifacts for docker-compose v${compose_version}..."
8692 fallback_compose "$docker_compose_url"
8693 }
8694
8695 chmod +x ${docker_compose_path}
8696
8697 # Download the SHA256 checksum
8698 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}')"
8699 echo "${DOCKER_COMPOSE_SHA256} ${docker_compose_path}" > docker-compose.sha256sum
8700 sha256sum -c docker-compose.sha256sum --ignore-missing
8701
8702 mkdir -p ${cli_plugins_dir}
8703 cp ${docker_compose_path} ${cli_plugins_dir}
8704 fi
8705 fi
8706
8707 # fallback method for compose-switch
8708 fallback_compose-switch() {
8709 local url=$1
8710 local repo_url=$(get_github_api_repo_url "$url")
8711 echo -e "\n(!) Failed to fetch the latest artifacts for compose-switch v${compose_switch_version}..."
8712 get_previous_version "$url" "$repo_url" compose_switch_version
8713 echo -e "\nAttempting to install v${compose_switch_version}"
8714 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
8715 }
8716 # Install docker-compose switch if not already installed - https://github.com/docker/compose-switch#manual-installation
8717 if [ "${INSTALL_DOCKER_COMPOSE_SWITCH}" = "true" ] && ! type compose-switch > /dev/null 2>&1; then
8718 if type docker-compose > /dev/null 2>&1; then
8719 echo "(*) Installing compose-switch..."
8720 current_compose_path="$(command -v docker-compose)"
8721 target_compose_path="$(dirname "${current_compose_path}")/docker-compose-v1"
8722 compose_switch_version="latest"
8723 compose_switch_url="https://github.com/docker/compose-switch"
8724 # Try to get latest version, fallback to known stable version if GitHub API fails
8725 set +e
8726 find_version_from_git_tags compose_switch_version "$compose_switch_url"
8727 if [ $? -ne 0 ] || [ -z "${compose_switch_version}" ] || [ "${compose_switch_version}" = "latest" ]; then
8728 echo "(*) GitHub API rate limited or failed, using fallback method"
8729 fallback_compose-switch "$compose_switch_url"
8730 fi
8731 set -e
8732
8733 # Map architecture for compose-switch downloads
8734 case "${architecture}" in
8735 amd64|x86_64) target_switch_arch=amd64 ;;
8736 arm64|aarch64) target_switch_arch=arm64 ;;
8737 *) target_switch_arch=${architecture} ;;
8738 esac
8739 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"
8740 chmod +x /usr/local/bin/compose-switch
8741 # TODO: Verify checksum once available: https://github.com/docker/compose-switch/issues/11
8742 # Setup v1 CLI as alternative in addition to compose-switch (which maps to v2)
8743 mv "${current_compose_path}" "${target_compose_path}"
8744 update-alternatives --install ${docker_compose_path} docker-compose /usr/local/bin/compose-switch 99
8745 update-alternatives --install ${docker_compose_path} docker-compose "${target_compose_path}" 1
8746 else
8747 err "Skipping installation of compose-switch as docker compose is unavailable..."
8748 fi
8749 fi
8750
8751 # If init file already exists, exit
8752 if [ -f "/usr/local/share/docker-init.sh" ]; then
8753 echo "/usr/local/share/docker-init.sh already exists, so exiting."
8754 # Clean up
8755 rm -rf /var/lib/apt/lists/*
8756 exit 0
8757 fi
8758 echo "docker-init doesn't exist, adding..."
8759
8760 if ! cat /etc/group | grep -e "^docker:" > /dev/null 2>&1; then
8761 groupadd -r docker
8762 fi
8763
8764 usermod -aG docker ${USERNAME}
8765
8766 # fallback for docker/buildx
8767 fallback_buildx() {
8768 local url=$1
8769 local repo_url=$(get_github_api_repo_url "$url")
8770 echo -e "\n(!) Failed to fetch the latest artifacts for docker buildx v${buildx_version}..."
8771 get_previous_version "$url" "$repo_url" buildx_version
8772 buildx_file_name="buildx-v${buildx_version}.linux-${target_buildx_arch}"
8773 echo -e "\nAttempting to install v${buildx_version}"
8774 wget https://github.com/docker/buildx/releases/download/v${buildx_version}/${buildx_file_name}
8775 }
8776
8777 if [ "${INSTALL_DOCKER_BUILDX}" = "true" ]; then
8778 buildx_version="latest"
8779 docker_buildx_url="https://github.com/docker/buildx"
8780 find_version_from_git_tags buildx_version "$docker_buildx_url" "refs/tags/v"
8781 echo "(*) Installing buildx ${buildx_version}..."
8782
8783 # Map architecture for buildx downloads
8784 case "${architecture}" in
8785 amd64|x86_64) target_buildx_arch=amd64 ;;
8786 arm64|aarch64) target_buildx_arch=arm64 ;;
8787 *) target_buildx_arch=${architecture} ;;
8788 esac
8789
8790 buildx_file_name="buildx-v${buildx_version}.linux-${target_buildx_arch}"
8791
8792 cd /tmp
8793 wget https://github.com/docker/buildx/releases/download/v${buildx_version}/${buildx_file_name} || fallback_buildx "$docker_buildx_url"
8794
8795 docker_home="/usr/libexec/docker"
8796 cli_plugins_dir="${docker_home}/cli-plugins"
8797
8798 mkdir -p ${cli_plugins_dir}
8799 mv ${buildx_file_name} ${cli_plugins_dir}/docker-buildx
8800 chmod +x ${cli_plugins_dir}/docker-buildx
8801
8802 chown -R "${USERNAME}:docker" "${docker_home}"
8803 chmod -R g+r+w "${docker_home}"
8804 find "${docker_home}" -type d -print0 | xargs -n 1 -0 chmod g+s
8805 fi
8806
8807 DOCKER_DEFAULT_IP6_TABLES=""
8808 if [ "$DISABLE_IP6_TABLES" == true ]; then
8809 requested_version=""
8810 # checking whether the version requested either is in semver format or just a number denoting the major version
8811 # and, extracting the major version number out of the two scenarios
8812 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-]+)*))?$"
8813 if echo "$DOCKER_VERSION" | grep -Eq $semver_regex; then
8814 requested_version=$(echo $DOCKER_VERSION | cut -d. -f1)
8815 elif echo "$DOCKER_VERSION" | grep -Eq "^[1-9][0-9]*$"; then
8816 requested_version=$DOCKER_VERSION
8817 fi
8818 if [ "$DOCKER_VERSION" = "latest" ] || [[ -n "$requested_version" && "$requested_version" -ge 27 ]] ; then
8819 DOCKER_DEFAULT_IP6_TABLES="--ip6tables=false"
8820 echo "(!) As requested, passing '${DOCKER_DEFAULT_IP6_TABLES}'"
8821 fi
8822 fi
8823
8824 if [ ! -d /usr/local/share ]; then
8825 mkdir -p /usr/local/share
8826 fi
8827
8828 tee /usr/local/share/docker-init.sh > /dev/null \
8829 << EOF
8830 #!/bin/sh
8831 #-------------------------------------------------------------------------------------------------------------
8832 # Copyright (c) Microsoft Corporation. All rights reserved.
8833 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
8834 #-------------------------------------------------------------------------------------------------------------
8835
8836 set -e
8837
8838 AZURE_DNS_AUTO_DETECTION=${AZURE_DNS_AUTO_DETECTION}
8839 DOCKER_DEFAULT_ADDRESS_POOL=${DOCKER_DEFAULT_ADDRESS_POOL}
8840 DOCKER_DEFAULT_IP6_TABLES=${DOCKER_DEFAULT_IP6_TABLES}
8841 EOF
8842
8843 tee -a /usr/local/share/docker-init.sh > /dev/null \
8844 << 'EOF'
8845 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'
8846 # explicitly remove dockerd and containerd PID file to ensure that it can start properly if it was stopped uncleanly
8847 find /run /var/run -iname 'docker*.pid' -delete || :
8848 find /run /var/run -iname 'container*.pid' -delete || :
8849
8850 # -- Start: dind wrapper script --
8851 # Maintained: https://github.com/moby/moby/blob/master/hack/dind
8852
8853 export container=docker
8854
8855 if [ -d /sys/kernel/security ] && ! mountpoint -q /sys/kernel/security; then
8856 mount -t securityfs none /sys/kernel/security || {
8857 echo >&2 'Could not mount /sys/kernel/security.'
8858 echo >&2 'AppArmor detection and --privileged mode might break.'
8859 }
8860 fi
8861
8862 # Mount /tmp (conditionally)
8863 if ! mountpoint -q /tmp; then
8864 mount -t tmpfs none /tmp
8865 fi
8866
8867 set_cgroup_nesting()
8868 {
8869 # cgroup v2: enable nesting
8870 if [ -f /sys/fs/cgroup/cgroup.controllers ]; then
8871 # move the processes from the root group to the /init group,
8872 # otherwise writing subtree_control fails with EBUSY.
8873 # An error during moving non-existent process (i.e., "cat") is ignored.
8874 mkdir -p /sys/fs/cgroup/init
8875 xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs || :
8876 # enable controllers
8877 sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers \
8878 > /sys/fs/cgroup/cgroup.subtree_control
8879 fi
8880 }
8881
8882 # Set cgroup nesting, retrying if necessary
8883 retry_cgroup_nesting=0
8884
8885 until [ "${retry_cgroup_nesting}" -eq "5" ];
8886 do
8887 set +e
8888 set_cgroup_nesting
8889
8890 if [ $? -ne 0 ]; then
8891 echo "(*) cgroup v2: Failed to enable nesting, retrying..."
8892 else
8893 break
8894 fi
8895
8896 retry_cgroup_nesting=`expr $retry_cgroup_nesting + 1`
8897 set -e
8898 done
8899
8900 # -- End: dind wrapper script --
8901
8902 # Handle DNS
8903 set +e
8904 cat /etc/resolv.conf | grep -i 'internal.cloudapp.net' > /dev/null 2>&1
8905 if [ $? -eq 0 ] && [ "${AZURE_DNS_AUTO_DETECTION}" = "true" ]
8906 then
8907 echo "Setting dockerd Azure DNS."
8908 CUSTOMDNS="--dns 168.63.129.16"
8909 else
8910 echo "Not setting dockerd DNS manually."
8911 CUSTOMDNS=""
8912 fi
8913 set -e
8914
8915 if [ -z "$DOCKER_DEFAULT_ADDRESS_POOL" ]
8916 then
8917 DEFAULT_ADDRESS_POOL=""
8918 else
8919 DEFAULT_ADDRESS_POOL="--default-address-pool $DOCKER_DEFAULT_ADDRESS_POOL"
8920 fi
8921
8922 # Start docker/moby engine
8923 ( dockerd $CUSTOMDNS $DEFAULT_ADDRESS_POOL $DOCKER_DEFAULT_IP6_TABLES > /tmp/dockerd.log 2>&1 ) &
8924 INNEREOF
8925 )"
8926
8927 sudo_if() {
8928 COMMAND="$*"
8929
8930 if [ "$(id -u)" -ne 0 ]; then
8931 sudo $COMMAND
8932 else
8933 $COMMAND
8934 fi
8935 }
8936
8937 retry_docker_start_count=0
8938 docker_ok="false"
8939
8940 until [ "${docker_ok}" = "true" ] || [ "${retry_docker_start_count}" -eq "5" ];
8941 do
8942 # Start using sudo if not invoked as root
8943 if [ "$(id -u)" -ne 0 ]; then
8944 sudo /bin/sh -c "${dockerd_start}"
8945 else
8946 eval "${dockerd_start}"
8947 fi
8948
8949 retry_count=0
8950 until [ "${docker_ok}" = "true" ] || [ "${retry_count}" -eq "5" ];
8951 do
8952 sleep 1s
8953 set +e
8954 docker info > /dev/null 2>&1 && docker_ok="true"
8955 set -e
8956
8957 retry_count=`expr $retry_count + 1`
8958 done
8959
8960 if [ "${docker_ok}" != "true" ] && [ "${retry_docker_start_count}" != "4" ]; then
8961 echo "(*) Failed to start docker, retrying..."
8962 set +e
8963 sudo_if pkill dockerd
8964 sudo_if pkill containerd
8965 set -e
8966 fi
8967
8968 retry_docker_start_count=`expr $retry_docker_start_count + 1`
8969 done
8970
8971 # Execute whatever commands were passed in (if any). This allows us
8972 # to set this script to ENTRYPOINT while still executing the default CMD.
8973 exec "$@"
8974 EOF
8975
8976 chmod +x /usr/local/share/docker-init.sh
8977 chown ${USERNAME}:root /usr/local/share/docker-init.sh
8978
8979 # Clean up
8980 rm -rf /var/lib/apt/lists/*
8981
8982 echo 'docker-in-docker-debian script has completed!'"#),
8983 ]).await;
8984
8985 return Ok(http::Response::builder()
8986 .status(200)
8987 .body(AsyncBody::from(response))
8988 .unwrap());
8989 }
8990 if parts.uri.path() == "/v2/devcontainers/features/go/manifests/1" {
8991 let response = r#"
8992 {
8993 "schemaVersion": 2,
8994 "mediaType": "application/vnd.oci.image.manifest.v1+json",
8995 "config": {
8996 "mediaType": "application/vnd.devcontainers",
8997 "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
8998 "size": 2
8999 },
9000 "layers": [
9001 {
9002 "mediaType": "application/vnd.devcontainers.layer.v1+tar",
9003 "digest": "sha256:eadd8a4757ee8ea6c1bc0aae22da49b7e5f2f1e32a87a5eac3cadeb7d2ccdad1",
9004 "size": 20992,
9005 "annotations": {
9006 "org.opencontainers.image.title": "devcontainer-feature-go.tgz"
9007 }
9008 }
9009 ],
9010 "annotations": {
9011 "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\"]}",
9012 "com.github.package.type": "devcontainer_feature"
9013 }
9014 }
9015 "#;
9016
9017 return Ok(http::Response::builder()
9018 .status(200)
9019 .body(http_client::AsyncBody::from(response))
9020 .unwrap());
9021 }
9022 if parts.uri.path()
9023 == "/v2/devcontainers/features/go/blobs/sha256:eadd8a4757ee8ea6c1bc0aae22da49b7e5f2f1e32a87a5eac3cadeb7d2ccdad1"
9024 {
9025 let response = build_tarball(vec![
9026 ("./devcontainer-feature.json", r#"
9027 {
9028 "id": "go",
9029 "version": "1.3.3",
9030 "name": "Go",
9031 "documentationURL": "https://github.com/devcontainers/features/tree/main/src/go",
9032 "description": "Installs Go and common Go utilities. Auto-detects latest version and installs needed dependencies.",
9033 "options": {
9034 "version": {
9035 "type": "string",
9036 "proposals": [
9037 "latest",
9038 "none",
9039 "1.24",
9040 "1.23"
9041 ],
9042 "default": "latest",
9043 "description": "Select or enter a Go version to install"
9044 },
9045 "golangciLintVersion": {
9046 "type": "string",
9047 "default": "latest",
9048 "description": "Version of golangci-lint to install"
9049 }
9050 },
9051 "init": true,
9052 "customizations": {
9053 "vscode": {
9054 "extensions": [
9055 "golang.Go"
9056 ],
9057 "settings": {
9058 "github.copilot.chat.codeGeneration.instructions": [
9059 {
9060 "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."
9061 }
9062 ]
9063 }
9064 }
9065 },
9066 "containerEnv": {
9067 "GOROOT": "/usr/local/go",
9068 "GOPATH": "/go",
9069 "PATH": "/usr/local/go/bin:/go/bin:${PATH}"
9070 },
9071 "capAdd": [
9072 "SYS_PTRACE"
9073 ],
9074 "securityOpt": [
9075 "seccomp=unconfined"
9076 ],
9077 "installsAfter": [
9078 "ghcr.io/devcontainers/features/common-utils"
9079 ]
9080 }
9081 "#),
9082 ("./install.sh", r#"
9083 #!/usr/bin/env bash
9084 #-------------------------------------------------------------------------------------------------------------
9085 # Copyright (c) Microsoft Corporation. All rights reserved.
9086 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information
9087 #-------------------------------------------------------------------------------------------------------------
9088 #
9089 # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/go.md
9090 # Maintainer: The VS Code and Codespaces Teams
9091
9092 TARGET_GO_VERSION="${VERSION:-"latest"}"
9093 GOLANGCILINT_VERSION="${GOLANGCILINTVERSION:-"latest"}"
9094
9095 TARGET_GOROOT="${TARGET_GOROOT:-"/usr/local/go"}"
9096 TARGET_GOPATH="${TARGET_GOPATH:-"/go"}"
9097 USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}"
9098 INSTALL_GO_TOOLS="${INSTALL_GO_TOOLS:-"true"}"
9099
9100 # https://www.google.com/linuxrepositories/
9101 GO_GPG_KEY_URI="https://dl.google.com/linux/linux_signing_key.pub"
9102
9103 set -e
9104
9105 if [ "$(id -u)" -ne 0 ]; then
9106 echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
9107 exit 1
9108 fi
9109
9110 # Bring in ID, ID_LIKE, VERSION_ID, VERSION_CODENAME
9111 . /etc/os-release
9112 # Get an adjusted ID independent of distro variants
9113 MAJOR_VERSION_ID=$(echo ${VERSION_ID} | cut -d . -f 1)
9114 if [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ]; then
9115 ADJUSTED_ID="debian"
9116 elif [[ "${ID}" = "rhel" || "${ID}" = "fedora" || "${ID}" = "mariner" || "${ID_LIKE}" = *"rhel"* || "${ID_LIKE}" = *"fedora"* || "${ID_LIKE}" = *"mariner"* ]]; then
9117 ADJUSTED_ID="rhel"
9118 if [[ "${ID}" = "rhel" ]] || [[ "${ID}" = *"alma"* ]] || [[ "${ID}" = *"rocky"* ]]; then
9119 VERSION_CODENAME="rhel${MAJOR_VERSION_ID}"
9120 else
9121 VERSION_CODENAME="${ID}${MAJOR_VERSION_ID}"
9122 fi
9123 else
9124 echo "Linux distro ${ID} not supported."
9125 exit 1
9126 fi
9127
9128 if [ "${ADJUSTED_ID}" = "rhel" ] && [ "${VERSION_CODENAME-}" = "centos7" ]; then
9129 # As of 1 July 2024, mirrorlist.centos.org no longer exists.
9130 # Update the repo files to reference vault.centos.org.
9131 sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/*.repo
9132 sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/*.repo
9133 sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/*.repo
9134 fi
9135
9136 # Setup INSTALL_CMD & PKG_MGR_CMD
9137 if type apt-get > /dev/null 2>&1; then
9138 PKG_MGR_CMD=apt-get
9139 INSTALL_CMD="${PKG_MGR_CMD} -y install --no-install-recommends"
9140 elif type microdnf > /dev/null 2>&1; then
9141 PKG_MGR_CMD=microdnf
9142 INSTALL_CMD="${PKG_MGR_CMD} ${INSTALL_CMD_ADDL_REPOS} -y install --refresh --best --nodocs --noplugins --setopt=install_weak_deps=0"
9143 elif type dnf > /dev/null 2>&1; then
9144 PKG_MGR_CMD=dnf
9145 INSTALL_CMD="${PKG_MGR_CMD} ${INSTALL_CMD_ADDL_REPOS} -y install --refresh --best --nodocs --noplugins --setopt=install_weak_deps=0"
9146 else
9147 PKG_MGR_CMD=yum
9148 INSTALL_CMD="${PKG_MGR_CMD} ${INSTALL_CMD_ADDL_REPOS} -y install --noplugins --setopt=install_weak_deps=0"
9149 fi
9150
9151 # Clean up
9152 clean_up() {
9153 case ${ADJUSTED_ID} in
9154 debian)
9155 rm -rf /var/lib/apt/lists/*
9156 ;;
9157 rhel)
9158 rm -rf /var/cache/dnf/* /var/cache/yum/*
9159 rm -rf /tmp/yum.log
9160 rm -rf ${GPG_INSTALL_PATH}
9161 ;;
9162 esac
9163 }
9164 clean_up
9165
9166
9167 # Figure out correct version of a three part version number is not passed
9168 find_version_from_git_tags() {
9169 local variable_name=$1
9170 local requested_version=${!variable_name}
9171 if [ "${requested_version}" = "none" ]; then return; fi
9172 local repository=$2
9173 local prefix=${3:-"tags/v"}
9174 local separator=${4:-"."}
9175 local last_part_optional=${5:-"false"}
9176 if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then
9177 local escaped_separator=${separator//./\\.}
9178 local last_part
9179 if [ "${last_part_optional}" = "true" ]; then
9180 last_part="(${escaped_separator}[0-9]+)?"
9181 else
9182 last_part="${escaped_separator}[0-9]+"
9183 fi
9184 local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$"
9185 local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)"
9186 if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then
9187 declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)"
9188 else
9189 set +e
9190 declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")"
9191 set -e
9192 fi
9193 fi
9194 if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then
9195 echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2
9196 exit 1
9197 fi
9198 echo "${variable_name}=${!variable_name}"
9199 }
9200
9201 pkg_mgr_update() {
9202 case $ADJUSTED_ID in
9203 debian)
9204 if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then
9205 echo "Running apt-get update..."
9206 ${PKG_MGR_CMD} update -y
9207 fi
9208 ;;
9209 rhel)
9210 if [ ${PKG_MGR_CMD} = "microdnf" ]; then
9211 if [ "$(ls /var/cache/yum/* 2>/dev/null | wc -l)" = 0 ]; then
9212 echo "Running ${PKG_MGR_CMD} makecache ..."
9213 ${PKG_MGR_CMD} makecache
9214 fi
9215 else
9216 if [ "$(ls /var/cache/${PKG_MGR_CMD}/* 2>/dev/null | wc -l)" = 0 ]; then
9217 echo "Running ${PKG_MGR_CMD} check-update ..."
9218 set +e
9219 ${PKG_MGR_CMD} check-update
9220 rc=$?
9221 if [ $rc != 0 ] && [ $rc != 100 ]; then
9222 exit 1
9223 fi
9224 set -e
9225 fi
9226 fi
9227 ;;
9228 esac
9229 }
9230
9231 # Checks if packages are installed and installs them if not
9232 check_packages() {
9233 case ${ADJUSTED_ID} in
9234 debian)
9235 if ! dpkg -s "$@" > /dev/null 2>&1; then
9236 pkg_mgr_update
9237 ${INSTALL_CMD} "$@"
9238 fi
9239 ;;
9240 rhel)
9241 if ! rpm -q "$@" > /dev/null 2>&1; then
9242 pkg_mgr_update
9243 ${INSTALL_CMD} "$@"
9244 fi
9245 ;;
9246 esac
9247 }
9248
9249 # Ensure that login shells get the correct path if the user updated the PATH using ENV.
9250 rm -f /etc/profile.d/00-restore-env.sh
9251 echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh
9252 chmod +x /etc/profile.d/00-restore-env.sh
9253
9254 # Some distributions do not install awk by default (e.g. Mariner)
9255 if ! type awk >/dev/null 2>&1; then
9256 check_packages awk
9257 fi
9258
9259 # Determine the appropriate non-root user
9260 if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
9261 USERNAME=""
9262 POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
9263 for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do
9264 if id -u ${CURRENT_USER} > /dev/null 2>&1; then
9265 USERNAME=${CURRENT_USER}
9266 break
9267 fi
9268 done
9269 if [ "${USERNAME}" = "" ]; then
9270 USERNAME=root
9271 fi
9272 elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then
9273 USERNAME=root
9274 fi
9275
9276 export DEBIAN_FRONTEND=noninteractive
9277
9278 check_packages ca-certificates gnupg2 tar gcc make pkg-config
9279
9280 if [ $ADJUSTED_ID = "debian" ]; then
9281 check_packages g++ libc6-dev
9282 else
9283 check_packages gcc-c++ glibc-devel
9284 fi
9285 # Install curl, git, other dependencies if missing
9286 if ! type curl > /dev/null 2>&1; then
9287 check_packages curl
9288 fi
9289 if ! type git > /dev/null 2>&1; then
9290 check_packages git
9291 fi
9292 # Some systems, e.g. Mariner, still a few more packages
9293 if ! type as > /dev/null 2>&1; then
9294 check_packages binutils
9295 fi
9296 if ! [ -f /usr/include/linux/errno.h ]; then
9297 check_packages kernel-headers
9298 fi
9299 # Minimal RHEL install may need findutils installed
9300 if ! [ -f /usr/bin/find ]; then
9301 check_packages findutils
9302 fi
9303
9304 # Get closest match for version number specified
9305 find_version_from_git_tags TARGET_GO_VERSION "https://go.googlesource.com/go" "tags/go" "." "true"
9306
9307 architecture="$(uname -m)"
9308 case $architecture in
9309 x86_64) architecture="amd64";;
9310 aarch64 | armv8*) architecture="arm64";;
9311 aarch32 | armv7* | armvhf*) architecture="armv6l";;
9312 i?86) architecture="386";;
9313 *) echo "(!) Architecture $architecture unsupported"; exit 1 ;;
9314 esac
9315
9316 # Install Go
9317 umask 0002
9318 if ! cat /etc/group | grep -e "^golang:" > /dev/null 2>&1; then
9319 groupadd -r golang
9320 fi
9321 usermod -a -G golang "${USERNAME}"
9322 mkdir -p "${TARGET_GOROOT}" "${TARGET_GOPATH}"
9323
9324 if [[ "${TARGET_GO_VERSION}" != "none" ]] && [[ "$(go version 2>/dev/null)" != *"${TARGET_GO_VERSION}"* ]]; then
9325 # Use a temporary location for gpg keys to avoid polluting image
9326 export GNUPGHOME="/tmp/tmp-gnupg"
9327 mkdir -p ${GNUPGHOME}
9328 chmod 700 ${GNUPGHOME}
9329 curl -sSL -o /tmp/tmp-gnupg/golang_key "${GO_GPG_KEY_URI}"
9330 gpg -q --import /tmp/tmp-gnupg/golang_key
9331 echo "Downloading Go ${TARGET_GO_VERSION}..."
9332 set +e
9333 curl -fsSL -o /tmp/go.tar.gz "https://golang.org/dl/go${TARGET_GO_VERSION}.linux-${architecture}.tar.gz"
9334 exit_code=$?
9335 set -e
9336 if [ "$exit_code" != "0" ]; then
9337 echo "(!) Download failed."
9338 # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios.
9339 set +e
9340 major="$(echo "${TARGET_GO_VERSION}" | grep -oE '^[0-9]+' || echo '')"
9341 minor="$(echo "${TARGET_GO_VERSION}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')"
9342 breakfix="$(echo "${TARGET_GO_VERSION}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')"
9343 # Handle Go's odd version pattern where "0" releases omit the last part
9344 if [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then
9345 ((minor=minor-1))
9346 TARGET_GO_VERSION="${major}.${minor}"
9347 # Look for latest version from previous minor release
9348 find_version_from_git_tags TARGET_GO_VERSION "https://go.googlesource.com/go" "tags/go" "." "true"
9349 else
9350 ((breakfix=breakfix-1))
9351 if [ "${breakfix}" = "0" ]; then
9352 TARGET_GO_VERSION="${major}.${minor}"
9353 else
9354 TARGET_GO_VERSION="${major}.${minor}.${breakfix}"
9355 fi
9356 fi
9357 set -e
9358 echo "Trying ${TARGET_GO_VERSION}..."
9359 curl -fsSL -o /tmp/go.tar.gz "https://golang.org/dl/go${TARGET_GO_VERSION}.linux-${architecture}.tar.gz"
9360 fi
9361 curl -fsSL -o /tmp/go.tar.gz.asc "https://golang.org/dl/go${TARGET_GO_VERSION}.linux-${architecture}.tar.gz.asc"
9362 gpg --verify /tmp/go.tar.gz.asc /tmp/go.tar.gz
9363 echo "Extracting Go ${TARGET_GO_VERSION}..."
9364 tar -xzf /tmp/go.tar.gz -C "${TARGET_GOROOT}" --strip-components=1
9365 rm -rf /tmp/go.tar.gz /tmp/go.tar.gz.asc /tmp/tmp-gnupg
9366 else
9367 echo "(!) Go is already installed with version ${TARGET_GO_VERSION}. Skipping."
9368 fi
9369
9370 # Install Go tools that are isImportant && !replacedByGopls based on
9371 # https://github.com/golang/vscode-go/blob/v0.38.0/src/goToolsInformation.ts
9372 GO_TOOLS="\
9373 golang.org/x/tools/gopls@latest \
9374 honnef.co/go/tools/cmd/staticcheck@latest \
9375 golang.org/x/lint/golint@latest \
9376 github.com/mgechev/revive@latest \
9377 github.com/go-delve/delve/cmd/dlv@latest \
9378 github.com/fatih/gomodifytags@latest \
9379 github.com/haya14busa/goplay/cmd/goplay@latest \
9380 github.com/cweill/gotests/gotests@latest \
9381 github.com/josharian/impl@latest"
9382
9383 if [ "${INSTALL_GO_TOOLS}" = "true" ]; then
9384 echo "Installing common Go tools..."
9385 export PATH=${TARGET_GOROOT}/bin:${PATH}
9386 export GOPATH=/tmp/gotools
9387 export GOCACHE="${GOPATH}/cache"
9388
9389 mkdir -p "${GOPATH}" /usr/local/etc/vscode-dev-containers "${TARGET_GOPATH}/bin"
9390 cd "${GOPATH}"
9391
9392 # Use go get for versions of go under 1.16
9393 go_install_command=install
9394 if [[ "1.16" > "$(go version | grep -oP 'go\K[0-9]+\.[0-9]+(\.[0-9]+)?')" ]]; then
9395 export GO111MODULE=on
9396 go_install_command=get
9397 echo "Go version < 1.16, using go get."
9398 fi
9399
9400 (echo "${GO_TOOLS}" | xargs -n 1 go ${go_install_command} -v )2>&1 | tee -a /usr/local/etc/vscode-dev-containers/go.log
9401
9402 # Move Go tools into path
9403 if [ -d "${GOPATH}/bin" ]; then
9404 mv "${GOPATH}/bin"/* "${TARGET_GOPATH}/bin/"
9405 fi
9406
9407 # Install golangci-lint from precompiled binaries
9408 if [ "$GOLANGCILINT_VERSION" = "latest" ] || [ "$GOLANGCILINT_VERSION" = "" ]; then
9409 echo "Installing golangci-lint latest..."
9410 curl -fsSL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \
9411 sh -s -- -b "${TARGET_GOPATH}/bin"
9412 else
9413 echo "Installing golangci-lint ${GOLANGCILINT_VERSION}..."
9414 curl -fsSL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \
9415 sh -s -- -b "${TARGET_GOPATH}/bin" "v${GOLANGCILINT_VERSION}"
9416 fi
9417
9418 # Remove Go tools temp directory
9419 rm -rf "${GOPATH}"
9420 fi
9421
9422
9423 chown -R "${USERNAME}:golang" "${TARGET_GOROOT}" "${TARGET_GOPATH}"
9424 chmod -R g+r+w "${TARGET_GOROOT}" "${TARGET_GOPATH}"
9425 find "${TARGET_GOROOT}" -type d -print0 | xargs -n 1 -0 chmod g+s
9426 find "${TARGET_GOPATH}" -type d -print0 | xargs -n 1 -0 chmod g+s
9427
9428 # Clean up
9429 clean_up
9430
9431 echo "Done!"
9432 "#),
9433 ])
9434 .await;
9435 return Ok(http::Response::builder()
9436 .status(200)
9437 .body(AsyncBody::from(response))
9438 .unwrap());
9439 }
9440 if parts.uri.path() == "/v2/devcontainers/features/aws-cli/manifests/1" {
9441 let response = r#"
9442 {
9443 "schemaVersion": 2,
9444 "mediaType": "application/vnd.oci.image.manifest.v1+json",
9445 "config": {
9446 "mediaType": "application/vnd.devcontainers",
9447 "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
9448 "size": 2
9449 },
9450 "layers": [
9451 {
9452 "mediaType": "application/vnd.devcontainers.layer.v1+tar",
9453 "digest": "sha256:4e9b04b394fb63e297b3d5f58185406ea45bddb639c2ba83b5a8394643cd5b13",
9454 "size": 19968,
9455 "annotations": {
9456 "org.opencontainers.image.title": "devcontainer-feature-aws-cli.tgz"
9457 }
9458 }
9459 ],
9460 "annotations": {
9461 "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\"]}",
9462 "com.github.package.type": "devcontainer_feature"
9463 }
9464 }"#;
9465 return Ok(http::Response::builder()
9466 .status(200)
9467 .body(AsyncBody::from(response))
9468 .unwrap());
9469 }
9470 if parts.uri.path()
9471 == "/v2/devcontainers/features/aws-cli/blobs/sha256:4e9b04b394fb63e297b3d5f58185406ea45bddb639c2ba83b5a8394643cd5b13"
9472 {
9473 let response = build_tarball(vec![
9474 (
9475 "./devcontainer-feature.json",
9476 r#"
9477{
9478 "id": "aws-cli",
9479 "version": "1.1.3",
9480 "name": "AWS CLI",
9481 "documentationURL": "https://github.com/devcontainers/features/tree/main/src/aws-cli",
9482 "description": "Installs the AWS CLI along with needed dependencies. Useful for base Dockerfiles that often are missing required install dependencies like gpg.",
9483 "options": {
9484 "version": {
9485 "type": "string",
9486 "proposals": [
9487 "latest"
9488 ],
9489 "default": "latest",
9490 "description": "Select or enter an AWS CLI version."
9491 },
9492 "verbose": {
9493 "type": "boolean",
9494 "default": true,
9495 "description": "Suppress verbose output."
9496 }
9497 },
9498 "customizations": {
9499 "vscode": {
9500 "extensions": [
9501 "AmazonWebServices.aws-toolkit-vscode"
9502 ],
9503 "settings": {
9504 "github.copilot.chat.codeGeneration.instructions": [
9505 {
9506 "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."
9507 }
9508 ]
9509 }
9510 }
9511 },
9512 "installsAfter": [
9513 "ghcr.io/devcontainers/features/common-utils"
9514 ]
9515}
9516 "#,
9517 ),
9518 (
9519 "./install.sh",
9520 r#"#!/usr/bin/env bash
9521 #-------------------------------------------------------------------------------------------------------------
9522 # Copyright (c) Microsoft Corporation. All rights reserved.
9523 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
9524 #-------------------------------------------------------------------------------------------------------------
9525 #
9526 # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/awscli.md
9527 # Maintainer: The VS Code and Codespaces Teams
9528
9529 set -e
9530
9531 # Clean up
9532 rm -rf /var/lib/apt/lists/*
9533
9534 VERSION=${VERSION:-"latest"}
9535 VERBOSE=${VERBOSE:-"true"}
9536
9537 AWSCLI_GPG_KEY=FB5DB77FD5C118B80511ADA8A6310ACC4672475C
9538 AWSCLI_GPG_KEY_MATERIAL="-----BEGIN PGP PUBLIC KEY BLOCK-----
9539
9540 mQINBF2Cr7UBEADJZHcgusOJl7ENSyumXh85z0TRV0xJorM2B/JL0kHOyigQluUG
9541 ZMLhENaG0bYatdrKP+3H91lvK050pXwnO/R7fB/FSTouki4ciIx5OuLlnJZIxSzx
9542 PqGl0mkxImLNbGWoi6Lto0LYxqHN2iQtzlwTVmq9733zd3XfcXrZ3+LblHAgEt5G
9543 TfNxEKJ8soPLyWmwDH6HWCnjZ/aIQRBTIQ05uVeEoYxSh6wOai7ss/KveoSNBbYz
9544 gbdzoqI2Y8cgH2nbfgp3DSasaLZEdCSsIsK1u05CinE7k2qZ7KgKAUIcT/cR/grk
9545 C6VwsnDU0OUCideXcQ8WeHutqvgZH1JgKDbznoIzeQHJD238GEu+eKhRHcz8/jeG
9546 94zkcgJOz3KbZGYMiTh277Fvj9zzvZsbMBCedV1BTg3TqgvdX4bdkhf5cH+7NtWO
9547 lrFj6UwAsGukBTAOxC0l/dnSmZhJ7Z1KmEWilro/gOrjtOxqRQutlIqG22TaqoPG
9548 fYVN+en3Zwbt97kcgZDwqbuykNt64oZWc4XKCa3mprEGC3IbJTBFqglXmZ7l9ywG
9549 EEUJYOlb2XrSuPWml39beWdKM8kzr1OjnlOm6+lpTRCBfo0wa9F8YZRhHPAkwKkX
9550 XDeOGpWRj4ohOx0d2GWkyV5xyN14p2tQOCdOODmz80yUTgRpPVQUtOEhXQARAQAB
9551 tCFBV1MgQ0xJIFRlYW0gPGF3cy1jbGlAYW1hem9uLmNvbT6JAlQEEwEIAD4WIQT7
9552 Xbd/1cEYuAURraimMQrMRnJHXAUCXYKvtQIbAwUJB4TOAAULCQgHAgYVCgkICwIE
9553 FgIDAQIeAQIXgAAKCRCmMQrMRnJHXJIXEAChLUIkg80uPUkGjE3jejvQSA1aWuAM
9554 yzy6fdpdlRUz6M6nmsUhOExjVIvibEJpzK5mhuSZ4lb0vJ2ZUPgCv4zs2nBd7BGJ
9555 MxKiWgBReGvTdqZ0SzyYH4PYCJSE732x/Fw9hfnh1dMTXNcrQXzwOmmFNNegG0Ox
9556 au+VnpcR5Kz3smiTrIwZbRudo1ijhCYPQ7t5CMp9kjC6bObvy1hSIg2xNbMAN/Do
9557 ikebAl36uA6Y/Uczjj3GxZW4ZWeFirMidKbtqvUz2y0UFszobjiBSqZZHCreC34B
9558 hw9bFNpuWC/0SrXgohdsc6vK50pDGdV5kM2qo9tMQ/izsAwTh/d/GzZv8H4lV9eO
9559 tEis+EpR497PaxKKh9tJf0N6Q1YLRHof5xePZtOIlS3gfvsH5hXA3HJ9yIxb8T0H
9560 QYmVr3aIUse20i6meI3fuV36VFupwfrTKaL7VXnsrK2fq5cRvyJLNzXucg0WAjPF
9561 RrAGLzY7nP1xeg1a0aeP+pdsqjqlPJom8OCWc1+6DWbg0jsC74WoesAqgBItODMB
9562 rsal1y/q+bPzpsnWjzHV8+1/EtZmSc8ZUGSJOPkfC7hObnfkl18h+1QtKTjZme4d
9563 H17gsBJr+opwJw/Zio2LMjQBOqlm3K1A4zFTh7wBC7He6KPQea1p2XAMgtvATtNe
9564 YLZATHZKTJyiqA==
9565 =vYOk
9566 -----END PGP PUBLIC KEY BLOCK-----"
9567
9568 if [ "$(id -u)" -ne 0 ]; then
9569 echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
9570 exit 1
9571 fi
9572
9573 apt_get_update()
9574 {
9575 if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then
9576 echo "Running apt-get update..."
9577 apt-get update -y
9578 fi
9579 }
9580
9581 # Checks if packages are installed and installs them if not
9582 check_packages() {
9583 if ! dpkg -s "$@" > /dev/null 2>&1; then
9584 apt_get_update
9585 apt-get -y install --no-install-recommends "$@"
9586 fi
9587 }
9588
9589 export DEBIAN_FRONTEND=noninteractive
9590
9591 check_packages curl ca-certificates gpg dirmngr unzip bash-completion less
9592
9593 verify_aws_cli_gpg_signature() {
9594 local filePath=$1
9595 local sigFilePath=$2
9596 local awsGpgKeyring=aws-cli-public-key.gpg
9597
9598 echo "${AWSCLI_GPG_KEY_MATERIAL}" | gpg --dearmor > "./${awsGpgKeyring}"
9599 gpg --batch --quiet --no-default-keyring --keyring "./${awsGpgKeyring}" --verify "${sigFilePath}" "${filePath}"
9600 local status=$?
9601
9602 rm "./${awsGpgKeyring}"
9603
9604 return ${status}
9605 }
9606
9607 install() {
9608 local scriptZipFile=awscli.zip
9609 local scriptSigFile=awscli.sig
9610
9611 # See Linux install docs at https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html
9612 if [ "${VERSION}" != "latest" ]; then
9613 local versionStr=-${VERSION}
9614 fi
9615 architecture=$(dpkg --print-architecture)
9616 case "${architecture}" in
9617 amd64) architectureStr=x86_64 ;;
9618 arm64) architectureStr=aarch64 ;;
9619 *)
9620 echo "AWS CLI does not support machine architecture '$architecture'. Please use an x86-64 or ARM64 machine."
9621 exit 1
9622 esac
9623 local scriptUrl=https://awscli.amazonaws.com/awscli-exe-linux-${architectureStr}${versionStr}.zip
9624 curl "${scriptUrl}" -o "${scriptZipFile}"
9625 curl "${scriptUrl}.sig" -o "${scriptSigFile}"
9626
9627 verify_aws_cli_gpg_signature "$scriptZipFile" "$scriptSigFile"
9628 if (( $? > 0 )); then
9629 echo "Could not verify GPG signature of AWS CLI install script. Make sure you provided a valid version."
9630 exit 1
9631 fi
9632
9633 if [ "${VERBOSE}" = "false" ]; then
9634 unzip -q "${scriptZipFile}"
9635 else
9636 unzip "${scriptZipFile}"
9637 fi
9638
9639 ./aws/install
9640
9641 # kubectl bash completion
9642 mkdir -p /etc/bash_completion.d
9643 cp ./scripts/vendor/aws_bash_completer /etc/bash_completion.d/aws
9644
9645 # kubectl zsh completion
9646 if [ -e "${USERHOME}/.oh-my-zsh" ]; then
9647 mkdir -p "${USERHOME}/.oh-my-zsh/completions"
9648 cp ./scripts/vendor/aws_zsh_completer.sh "${USERHOME}/.oh-my-zsh/completions/_aws"
9649 chown -R "${USERNAME}" "${USERHOME}/.oh-my-zsh"
9650 fi
9651
9652 rm -rf ./aws
9653 }
9654
9655 echo "(*) Installing AWS CLI..."
9656
9657 install
9658
9659 # Clean up
9660 rm -rf /var/lib/apt/lists/*
9661
9662 echo "Done!""#,
9663 ),
9664 ("./scripts/", r#""#),
9665 (
9666 "./scripts/fetch-latest-completer-scripts.sh",
9667 r#"
9668 #!/bin/bash
9669 #-------------------------------------------------------------------------------------------------------------
9670 # Copyright (c) Microsoft Corporation. All rights reserved.
9671 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
9672 #-------------------------------------------------------------------------------------------------------------
9673 #
9674 # Docs: https://github.com/devcontainers/features/tree/main/src/aws-cli
9675 # Maintainer: The Dev Container spec maintainers
9676 #
9677 # Run this script to replace aws_bash_completer and aws_zsh_completer.sh with the latest and greatest available version
9678 #
9679 COMPLETER_SCRIPTS=$(dirname "${BASH_SOURCE[0]}")
9680 BASH_COMPLETER_SCRIPT="$COMPLETER_SCRIPTS/vendor/aws_bash_completer"
9681 ZSH_COMPLETER_SCRIPT="$COMPLETER_SCRIPTS/vendor/aws_zsh_completer.sh"
9682
9683 wget https://raw.githubusercontent.com/aws/aws-cli/v2/bin/aws_bash_completer -O "$BASH_COMPLETER_SCRIPT"
9684 chmod +x "$BASH_COMPLETER_SCRIPT"
9685
9686 wget https://raw.githubusercontent.com/aws/aws-cli/v2/bin/aws_zsh_completer.sh -O "$ZSH_COMPLETER_SCRIPT"
9687 chmod +x "$ZSH_COMPLETER_SCRIPT"
9688 "#,
9689 ),
9690 ("./scripts/vendor/", r#""#),
9691 (
9692 "./scripts/vendor/aws_bash_completer",
9693 r#"
9694 # Typically that would be added under one of the following paths:
9695 # - /etc/bash_completion.d
9696 # - /usr/local/etc/bash_completion.d
9697 # - /usr/share/bash-completion/completions
9698
9699 complete -C aws_completer aws
9700 "#,
9701 ),
9702 (
9703 "./scripts/vendor/aws_zsh_completer.sh",
9704 r#"
9705 # Source this file to activate auto completion for zsh using the bash
9706 # compatibility helper. Make sure to run `compinit` before, which should be
9707 # given usually.
9708 #
9709 # % source /path/to/zsh_complete.sh
9710 #
9711 # Typically that would be called somewhere in your .zshrc.
9712 #
9713 # Note, the overwrite of _bash_complete() is to export COMP_LINE and COMP_POINT
9714 # That is only required for zsh <= edab1d3dbe61da7efe5f1ac0e40444b2ec9b9570
9715 #
9716 # https://github.com/zsh-users/zsh/commit/edab1d3dbe61da7efe5f1ac0e40444b2ec9b9570
9717 #
9718 # zsh releases prior to that version do not export the required env variables!
9719
9720 autoload -Uz bashcompinit
9721 bashcompinit -i
9722
9723 _bash_complete() {
9724 local ret=1
9725 local -a suf matches
9726 local -x COMP_POINT COMP_CWORD
9727 local -a COMP_WORDS COMPREPLY BASH_VERSINFO
9728 local -x COMP_LINE="$words"
9729 local -A savejobstates savejobtexts
9730
9731 (( COMP_POINT = 1 + ${#${(j. .)words[1,CURRENT]}} + $#QIPREFIX + $#IPREFIX + $#PREFIX ))
9732 (( COMP_CWORD = CURRENT - 1))
9733 COMP_WORDS=( $words )
9734 BASH_VERSINFO=( 2 05b 0 1 release )
9735
9736 savejobstates=( ${(kv)jobstates} )
9737 savejobtexts=( ${(kv)jobtexts} )
9738
9739 [[ ${argv[${argv[(I)nospace]:-0}-1]} = -o ]] && suf=( -S '' )
9740
9741 matches=( ${(f)"$(compgen $@ -- ${words[CURRENT]})"} )
9742
9743 if [[ -n $matches ]]; then
9744 if [[ ${argv[${argv[(I)filenames]:-0}-1]} = -o ]]; then
9745 compset -P '*/' && matches=( ${matches##*/} )
9746 compset -S '/*' && matches=( ${matches%%/*} )
9747 compadd -Q -f "${suf[@]}" -a matches && ret=0
9748 else
9749 compadd -Q "${suf[@]}" -a matches && ret=0
9750 fi
9751 fi
9752
9753 if (( ret )); then
9754 if [[ ${argv[${argv[(I)default]:-0}-1]} = -o ]]; then
9755 _default "${suf[@]}" && ret=0
9756 elif [[ ${argv[${argv[(I)dirnames]:-0}-1]} = -o ]]; then
9757 _directories "${suf[@]}" && ret=0
9758 fi
9759 fi
9760
9761 return ret
9762 }
9763
9764 complete -C aws_completer aws
9765 "#,
9766 ),
9767 ]).await;
9768
9769 return Ok(http::Response::builder()
9770 .status(200)
9771 .body(AsyncBody::from(response))
9772 .unwrap());
9773 }
9774
9775 Ok(http::Response::builder()
9776 .status(404)
9777 .body(http_client::AsyncBody::default())
9778 .unwrap())
9779 })
9780 }
9781}
9782