diff --git a/cmd/devcontainer/src/cli.rs b/cmd/devcontainer/src/cli.rs index 4a590184f..800775591 100644 --- a/cmd/devcontainer/src/cli.rs +++ b/cmd/devcontainer/src/cli.rs @@ -168,10 +168,7 @@ pub fn resolve_command_help<'a>( let mut current = command_help(command)?; let mut consumed_args = 0; - loop { - let Some(next_arg) = args.get(consumed_args) else { - break; - }; + while let Some(next_arg) = args.get(consumed_args) { let Some(child) = child_command(¤t.path, next_arg) else { break; }; diff --git a/cmd/devcontainer/src/commands/common/config_resolution.rs b/cmd/devcontainer/src/commands/common/config_resolution.rs index e6f763b68..654c9491a 100644 --- a/cmd/devcontainer/src/commands/common/config_resolution.rs +++ b/cmd/devcontainer/src/commands/common/config_resolution.rs @@ -101,13 +101,20 @@ pub(crate) fn load_resolved_config(args: &[String]) -> Result<(PathBuf, PathBuf, }) .or_else(|| { Some( - crate::runtime::context::derived_workspace_mount(&workspace_folder, args) - .map(|derived| derived.remote_workspace_folder) - .unwrap_or_else(|| { - crate::runtime::context::default_remote_workspace_folder(Some( - &workspace_folder, - )) - }), + if crate::runtime::compose::uses_compose_config(&parsed) + && parsed.get("workspaceFolder").is_none() + && parsed.get("workspaceMount").is_none() + { + "/".to_string() + } else { + crate::runtime::context::derived_workspace_mount(&workspace_folder, args) + .map(|derived| derived.remote_workspace_folder) + .unwrap_or_else(|| { + crate::runtime::context::default_remote_workspace_folder(Some( + &workspace_folder, + )) + }) + }, ) }); let substituted = config::substitute_local_context( diff --git a/cmd/devcontainer/src/runtime/compose/args.rs b/cmd/devcontainer/src/runtime/compose/args.rs index 98f4aa48a..10a372252 100644 --- a/cmd/devcontainer/src/runtime/compose/args.rs +++ b/cmd/devcontainer/src/runtime/compose/args.rs @@ -47,10 +47,19 @@ pub(super) fn compose_args_owned( } pub(super) fn reject_unsupported_build_options(args: &[String]) -> Result<(), String> { - for flag in ["--cache-from", "--cache-to", "--platform", "--label"] { - if compose_build_option_is_present(args, flag) { - return Err(format!("{flag} not supported for compose builds.")); - } + if compose_build_option_is_present(args, "--cache-to") { + return Err("--cache-to not supported for compose builds.".to_string()); + } + if compose_build_option_is_present(args, "--platform") + || compose_build_option_is_present(args, "--push") + { + return Err("--platform or --push not supported.".to_string()); + } + if compose_build_option_is_present(args, "--output") { + return Err("--output not supported.".to_string()); + } + if compose_build_option_is_present(args, "--label") { + return Err("--label not supported for compose builds.".to_string()); } Ok(()) } diff --git a/cmd/devcontainer/src/runtime/compose/mod.rs b/cmd/devcontainer/src/runtime/compose/mod.rs index 74fe6fd94..ffa53891d 100644 --- a/cmd/devcontainer/src/runtime/compose/mod.rs +++ b/cmd/devcontainer/src/runtime/compose/mod.rs @@ -41,7 +41,11 @@ pub(crate) fn load_compose_spec(resolved: &ResolvedConfig) -> Result Resul )?; if spec.has_build { + let build_override_file = override_file::compose_build_override_file(&spec, args)?; let mut build_args = vec!["--pull".to_string()]; if common::has_flag(args, "--no-cache") || common::has_flag(args, "--build-no-cache") { build_args.push("--no-cache".to_string()); @@ -80,8 +85,12 @@ pub(crate) fn build_service(resolved: &ResolvedConfig, args: &[String]) -> Resul build_args.push(spec.service.clone()); let result = engine::run_compose( args, - args::compose_args_owned(&spec, "build", None, build_args), - )?; + args::compose_args_owned(&spec, "build", build_override_file.as_ref(), build_args), + ); + if let Some(build_override_file) = build_override_file { + let _ = std::fs::remove_file(build_override_file); + } + let result = result?; if result.status_code != 0 { return Err(engine::stderr_or_stdout(&result)); } @@ -115,17 +124,6 @@ pub(crate) fn build_service(resolved: &ResolvedConfig, args: &[String]) -> Resul return Ok(built_image); } - if common::has_flag(args, "--push") { - if let Some(image) = &spec.image { - let push_result = engine::run_engine(args, vec!["push".to_string(), image.clone()])?; - if push_result.status_code != 0 { - return Err(engine::stderr_or_stdout(&push_result)); - } - } else { - return Err("Compose build push requires the service to declare an image".to_string()); - } - } - Ok(spec .image .clone() @@ -137,6 +135,7 @@ pub(crate) fn up_service( args: &[String], remote_workspace_folder: &str, image_name: &str, + no_recreate: bool, ) -> Result<(), String> { let spec = load_compose_spec(resolved)? .ok_or_else(|| "Compose configuration was expected but not found".to_string())?; @@ -150,14 +149,28 @@ pub(crate) fn up_service( None }, )?; + let mut up_args = vec!["-d".to_string()]; + if no_recreate { + up_args.push("--no-recreate".to_string()); + } + if let Some(run_services) = resolved + .configuration + .get("runServices") + .and_then(Value::as_array) + .filter(|services| !services.is_empty()) + { + let mut has_primary_service = false; + for service in run_services.iter().filter_map(Value::as_str) { + has_primary_service |= service == spec.service; + up_args.push(service.to_string()); + } + if !has_primary_service { + up_args.push(spec.service.clone()); + } + } let result = engine::run_compose( args, - args::compose_args_with_override( - &spec, - "up", - &["-d", &spec.service], - override_file.as_ref(), - ), + args::compose_args_owned(&spec, "up", override_file.as_ref(), up_args), )?; if let Some(override_file) = override_file { let _ = std::fs::remove_file(override_file); diff --git a/cmd/devcontainer/src/runtime/compose/override_file.rs b/cmd/devcontainer/src/runtime/compose/override_file.rs index 4c14ebb5a..e7a865e0f 100644 --- a/cmd/devcontainer/src/runtime/compose/override_file.rs +++ b/cmd/devcontainer/src/runtime/compose/override_file.rs @@ -15,8 +15,39 @@ use super::super::container; use super::super::context::ResolvedConfig; use super::super::metadata::serialized_container_metadata; use super::super::paths::unique_temp_path; -use override_mounts::{compose_additional_volumes, compose_environment, compose_workspace_volume}; -use override_yaml::{escape_compose_label, escape_compose_scalar, render_compose_volume_entry}; +use super::service::{self, ServiceDefinition}; +use super::ComposeSpec; +use override_mounts::{ + compose_additional_volumes, compose_environment, compose_named_volumes, + compose_workspace_volume, +}; +use override_yaml::{ + escape_compose_label, escape_compose_scalar, render_compose_string_sequence, + render_compose_volume_entry, render_named_volume_entry, +}; + +pub(super) fn compose_build_override_file( + spec: &ComposeSpec, + args: &[String], +) -> Result, String> { + let cache_from = common::parse_option_values(args, "--cache-from"); + if cache_from.is_empty() { + return Ok(None); + } + + let mut content = service::read_version_prefix(&spec.files).unwrap_or_default(); + content.push_str(&format!( + "services:\n '{}':\n build:\n cache_from:\n", + spec.service + )); + for value in cache_from { + content.push_str(&format!(" - '{}'\n", escape_compose_scalar(&value))); + } + + let override_file = unique_temp_path("devcontainer-compose-build-override", Some("yml")); + std::fs::write(&override_file, content).map_err(|error| error.to_string())?; + Ok(Some(override_file)) +} pub(super) fn compose_metadata_override_file( resolved: &ResolvedConfig, @@ -24,6 +55,11 @@ pub(super) fn compose_metadata_override_file( remote_workspace_folder: &str, image_name: Option<&str>, ) -> Result, String> { + let service_name = resolved + .configuration + .get("service") + .and_then(Value::as_str) + .ok_or_else(|| "Compose configuration must define service".to_string())?; let metadata = serialized_container_metadata( &resolved.configuration, remote_workspace_folder, @@ -45,14 +81,12 @@ pub(super) fn compose_metadata_override_file( return Ok(None); } - let mut content = String::from("services:\n"); + let (version_prefix, service_definition) = compose_override_context(resolved, service_name); + let mut content = version_prefix; + content.push_str("services:\n"); content.push_str(&format!( " '{}':\n labels:{}\n", - resolved - .configuration - .get("service") - .and_then(Value::as_str) - .ok_or_else(|| "Compose configuration must define service".to_string())?, + service_name, labels .iter() .map(|label| format!("\n - '{}'", escape_compose_label(label))) @@ -64,15 +98,23 @@ pub(super) fn compose_metadata_override_file( escape_compose_scalar(image_name) )); } + content.push_str(&format!( + " entrypoint: {}\n", + compose_wrapper_entrypoint(resolved, service_definition.as_ref())? + )); + if let Some(command) = compose_wrapper_command(resolved, service_definition.as_ref())? { + content.push_str(&format!(" command: {command}\n")); + } let mut volumes = Vec::new(); if let Some(volume) = compose_workspace_volume(resolved, args, remote_workspace_folder) { volumes.push(volume); } - volumes.extend(compose_additional_volumes(resolved, args)); + volumes.extend(compose_additional_volumes(resolved, args)?); + let named_volumes = compose_named_volumes(&volumes); if !volumes.is_empty() { content.push_str("\n volumes:\n"); - for volume in volumes { - content.push_str(&render_compose_volume_entry(&volume)); + for volume in &volumes { + content.push_str(&render_compose_volume_entry(volume)); } } if let Some(environment) = compose_environment(&resolved.configuration) { @@ -131,27 +173,122 @@ pub(super) fn compose_metadata_override_file( content.push_str(&format!(" - '{}'\n", escape_compose_scalar(option))); } } - if let Some(entrypoint) = resolved - .configuration - .get("entrypoint") - .and_then(Value::as_str) - { - content.push_str(&format!( - " entrypoint: '{}'\n", - escape_compose_scalar(entrypoint) - )); - } if container::should_add_gpu_capability(&resolved.configuration, args)? { content.push_str( " deploy:\n resources:\n reservations:\n devices:\n - capabilities: [gpu]\n", ); } + if !named_volumes.is_empty() { + content.push_str("\nvolumes:\n"); + for named_volume in &named_volumes { + content.push_str(&render_named_volume_entry(named_volume)); + } + } let override_file = unique_override_file_path(); std::fs::write(&override_file, content).map_err(|error| error.to_string())?; Ok(Some(override_file)) } +fn compose_override_context( + resolved: &ResolvedConfig, + service_name: &str, +) -> (String, Option) { + let config_root = resolved + .config_file + .parent() + .unwrap_or(resolved.workspace_folder.as_path()); + let Ok(compose_files) = service::compose_files( + &resolved.configuration, + config_root, + &resolved.workspace_folder, + ) else { + return (String::new(), None); + }; + let version_prefix = service::read_version_prefix(&compose_files).unwrap_or_default(); + let service_definition = service::inspect_service_definition(&compose_files, service_name).ok(); + (version_prefix, service_definition) +} + +fn compose_wrapper_entrypoint( + resolved: &ResolvedConfig, + service_definition: Option<&ServiceDefinition>, +) -> Result { + let override_command = resolved + .configuration + .get("overrideCommand") + .and_then(Value::as_bool) + .unwrap_or(false); + let compose_entrypoint = + service_definition.and_then(|definition| definition.entrypoint.clone()); + let user_entrypoint = if override_command { + Vec::new() + } else { + compose_entrypoint.unwrap_or_default() + }; + let custom_entrypoints = merged_entrypoints(resolved).join("\n\n"); + let script = format!( + "echo Container started\ntrap \"exit 0\" 15\n{custom_entrypoints}\nexec \"$$@\"\nwhile sleep 1 & wait $$!; do :; done" + ); + let mut entrypoint = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + script, + "-".to_string(), + ]; + entrypoint.extend(user_entrypoint); + render_compose_string_sequence(&entrypoint) +} + +fn compose_wrapper_command( + resolved: &ResolvedConfig, + service_definition: Option<&ServiceDefinition>, +) -> Result, String> { + let override_command = resolved + .configuration + .get("overrideCommand") + .and_then(Value::as_bool) + .unwrap_or(false); + let compose_entrypoint = + service_definition.and_then(|definition| definition.entrypoint.clone()); + let compose_command = service_definition.and_then(|definition| definition.command.clone()); + let user_command = if override_command { + Some(Vec::new()) + } else if let Some(command) = compose_command.clone() { + Some(command) + } else if compose_entrypoint.is_some() { + Some(Vec::new()) + } else { + None + }; + if user_command == compose_command { + return Ok(None); + } + user_command + .map(|command| render_compose_string_sequence(&command)) + .transpose() +} + +fn merged_entrypoints(resolved: &ResolvedConfig) -> Vec { + if let Some(values) = resolved + .configuration + .get("entrypoints") + .and_then(Value::as_array) + { + return values + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect(); + } + resolved + .configuration + .get("entrypoint") + .and_then(Value::as_str) + .map(|value| vec![value.to_string()]) + .unwrap_or_default() +} + fn unique_override_file_path() -> PathBuf { unique_temp_path("devcontainer-compose-override", Some("yml")) } diff --git a/cmd/devcontainer/src/runtime/compose/override_mounts.rs b/cmd/devcontainer/src/runtime/compose/override_mounts.rs index 0273fb0cd..90c722342 100644 --- a/cmd/devcontainer/src/runtime/compose/override_mounts.rs +++ b/cmd/devcontainer/src/runtime/compose/override_mounts.rs @@ -5,6 +5,7 @@ use serde_json::{Map, Number, Value}; use crate::runtime::context::{ additional_mounts_for_workspace_target, workspace_mount_for_args, ResolvedConfig, }; +use crate::runtime::mounts::cli_mount_values; use crate::runtime::mounts::split_mount_options; pub(super) enum ComposeVolumeEntry { @@ -12,6 +13,11 @@ pub(super) enum ComposeVolumeEntry { Long(ComposeMountDefinition), } +pub(super) struct ComposeNamedVolume { + pub(super) name: String, + pub(super) external: bool, +} + pub(super) struct ComposeMountDefinition { pub(super) fields: Map, } @@ -35,13 +41,8 @@ pub(super) fn compose_workspace_volume( pub(super) fn compose_additional_volumes( resolved: &ResolvedConfig, args: &[String], -) -> Vec { - let mut volumes: Vec = resolved - .configuration - .get("mounts") - .and_then(Value::as_array) - .map(|mounts| mounts.iter().filter_map(compose_mount_definition).collect()) - .unwrap_or_default(); +) -> Result, String> { + let mut volumes = Vec::new(); if resolved.configuration.get("workspaceMount").is_none() { let remote_workspace_folder = crate::runtime::context::remote_workspace_folder_for_args(resolved, args); @@ -52,7 +53,65 @@ pub(super) fn compose_additional_volumes( .map(ComposeVolumeEntry::Long), ); } - volumes + volumes.extend( + resolved + .configuration + .get("mounts") + .and_then(Value::as_array) + .map(|mounts| { + mounts + .iter() + .filter_map(compose_mount_definition) + .collect::>() + }) + .unwrap_or_default(), + ); + volumes.extend( + cli_mount_values(args)? + .iter() + .filter_map(|mount| compose_mount_definition_from_str(mount)) + .map(ComposeVolumeEntry::Long), + ); + Ok(volumes) +} + +pub(super) fn compose_named_volumes(volumes: &[ComposeVolumeEntry]) -> Vec { + let mut named_volumes: Vec = Vec::new(); + for volume in volumes { + let ComposeVolumeEntry::Long(definition) = volume else { + continue; + }; + if definition.mount_type().unwrap_or("bind") != "volume" { + continue; + } + let Some(name) = definition + .fields + .get("source") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + else { + continue; + }; + let external = definition + .fields + .get("volume") + .and_then(Value::as_object) + .and_then(|volume| volume.get("external")) + .and_then(Value::as_bool) + .unwrap_or(false); + if let Some(existing) = named_volumes + .iter_mut() + .find(|existing| existing.name == name) + { + existing.external |= external; + continue; + } + named_volumes.push(ComposeNamedVolume { + name: name.to_string(), + external, + }); + } + named_volumes } fn compose_mount_definition(value: &Value) -> Option { @@ -116,7 +175,7 @@ fn compose_mount_definition(value: &Value) -> Option { ) { continue; } - fields.insert(key.clone(), value.clone()); + merge_mount_value(&mut fields, key, value.clone()); } Some(ComposeVolumeEntry::Long(ComposeMountDefinition { fields })) } @@ -264,6 +323,26 @@ fn insert_nested_mount_value( insert_nested_mount_value(child, &parents[1..], leaf, value); } +fn merge_mount_value(fields: &mut Map, key: &str, value: Value) { + if let Some(existing) = fields.get_mut(key) { + merge_mount_scalar_or_object(existing, value); + return; + } + + fields.insert(key.to_string(), value); +} + +fn merge_mount_scalar_or_object(existing: &mut Value, incoming: Value) { + match (existing, incoming) { + (Value::Object(existing), Value::Object(incoming)) => { + for (key, value) in incoming { + merge_mount_value(existing, &key, value); + } + } + (existing, incoming) => *existing = incoming, + } +} + pub(super) fn compose_environment(configuration: &Value) -> Option> { let env = configuration .get("containerEnv") diff --git a/cmd/devcontainer/src/runtime/compose/override_yaml.rs b/cmd/devcontainer/src/runtime/compose/override_yaml.rs index 267787cf0..27ca02af7 100644 --- a/cmd/devcontainer/src/runtime/compose/override_yaml.rs +++ b/cmd/devcontainer/src/runtime/compose/override_yaml.rs @@ -2,14 +2,14 @@ use serde_json::{Map, Value}; -use super::override_mounts::ComposeVolumeEntry; +use super::override_mounts::{ComposeNamedVolume, ComposeVolumeEntry}; pub(super) fn escape_compose_label(label: &str) -> String { label.replace('\'', "''").replace('$', "$$") } pub(super) fn escape_compose_scalar(value: &str) -> String { - value.replace('\'', "''") + value.replace('\'', "''").replace('$', "$$") } pub(super) fn render_compose_volume_entry(entry: &ComposeVolumeEntry) -> String { @@ -21,6 +21,18 @@ pub(super) fn render_compose_volume_entry(entry: &ComposeVolumeEntry) -> String } } +pub(super) fn render_compose_string_sequence(values: &[String]) -> Result { + serde_json::to_string(values).map_err(|error| error.to_string()) +} + +pub(super) fn render_named_volume_entry(entry: &ComposeNamedVolume) -> String { + let mut rendered = format!(" {}:\n", entry.name); + if entry.external { + rendered.push_str(" external: true\n"); + } + rendered +} + fn render_yaml_mapping_list_entry(entries: &Map) -> String { let mut rendered = String::new(); let mut iter = entries.iter(); diff --git a/cmd/devcontainer/src/runtime/compose/service.rs b/cmd/devcontainer/src/runtime/compose/service.rs index 682b0373a..3a3830596 100644 --- a/cmd/devcontainer/src/runtime/compose/service.rs +++ b/cmd/devcontainer/src/runtime/compose/service.rs @@ -1,5 +1,7 @@ //! Compose service inspection and build metadata helpers. +use std::ffi::OsString; +use std::fs; use std::path::{Path, PathBuf}; use serde_json::Value; @@ -13,15 +15,18 @@ pub(super) struct ServiceDefinition { pub(super) image: Option, pub(super) has_build: bool, pub(super) user: Option, + pub(super) entrypoint: Option>, + pub(super) command: Option>, } pub(super) fn compose_files( configuration: &Value, config_root: &Path, + workspace_root: &Path, ) -> Result, String> { match configuration.get("dockerComposeFile") { Some(Value::String(value)) => Ok(vec![resolve_relative(config_root, value)]), - Some(Value::Array(values)) => values + Some(Value::Array(values)) if !values.is_empty() => values .iter() .map(|value| { value @@ -30,11 +35,58 @@ pub(super) fn compose_files( .ok_or_else(|| "dockerComposeFile entries must be strings".to_string()) }) .collect(), + Some(Value::Array(_)) => default_compose_files(workspace_root), Some(_) => Err("dockerComposeFile must be a string or array of strings".to_string()), None => Err("Compose configuration must define dockerComposeFile".to_string()), } } +fn default_compose_files(workspace_root: &Path) -> Result, String> { + if let Some(compose_files) = + compose_files_from_env(std::env::var_os("COMPOSE_FILE"), workspace_root) + { + return Ok(compose_files); + } + + let env_file = workspace_root.join(".env"); + if let Ok(raw) = fs::read_to_string(&env_file) { + if let Some(value) = raw.lines().find_map(|line| { + line.trim() + .strip_prefix("COMPOSE_FILE=") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) { + if let Some(compose_files) = + compose_files_from_env(Some(OsString::from(value)), workspace_root) + { + return Ok(compose_files); + } + } + } + + let mut files = vec![workspace_root.join("docker-compose.yml")]; + let override_file = workspace_root.join("docker-compose.override.yml"); + if override_file.is_file() { + files.push(override_file); + } + Ok(files) +} + +fn compose_files_from_env(value: Option, workspace_root: &Path) -> Option> { + let value = value?; + let files = std::env::split_paths(&value) + .map(|path| { + if path.is_absolute() { + path + } else { + workspace_root.join(path) + } + }) + .collect::>(); + (!files.is_empty()).then_some(files) +} + pub(super) fn inspect_service_definition( compose_files: &[PathBuf], service: &str, @@ -42,6 +94,8 @@ pub(super) fn inspect_service_definition( let mut image = None; let mut has_build = false; let mut user = None; + let mut entrypoint = None; + let mut command = None; let mut found_service = false; for compose_file in compose_files { @@ -69,6 +123,16 @@ pub(super) fn inspect_service_definition( if let Some(value) = service_field(service_definition, "user").and_then(YamlValue::as_str) { user = Some(value.to_string()); } + if let Some(value) = + service_field(service_definition, "entrypoint").and_then(parse_service_command) + { + entrypoint = Some(value); + } + if let Some(value) = + service_field(service_definition, "command").and_then(parse_service_command) + { + command = Some(value); + } } if !found_service { @@ -81,6 +145,8 @@ pub(super) fn inspect_service_definition( image, has_build, user, + entrypoint, + command, }) } @@ -88,6 +154,98 @@ fn service_field<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a YamlValue> { mapping.get(YamlValue::String(key.to_string())) } +pub(super) fn read_version_prefix(compose_files: &[PathBuf]) -> Result { + let Some(first_compose_file) = compose_files.first() else { + return Ok(String::new()); + }; + let raw = fs::read_to_string(first_compose_file).map_err(|error| error.to_string())?; + let version = raw.lines().find_map(|line| { + line.trim_start() + .strip_prefix("version:") + .map(|_| line.trim()) + }); + Ok(version + .filter(|value| !value.is_empty()) + .map(|value| format!("{value}\n\n")) + .unwrap_or_default()) +} + +fn parse_service_command(value: &YamlValue) -> Option> { + match value { + YamlValue::String(text) => Some(split_shell_words(text)), + YamlValue::Sequence(values) => Some( + values + .iter() + .filter_map(yaml_scalar_to_string) + .collect::>(), + ), + YamlValue::Null => Some(Vec::new()), + _ => None, + } +} + +fn yaml_scalar_to_string(value: &YamlValue) -> Option { + match value { + YamlValue::String(text) => Some(text.to_string()), + YamlValue::Bool(value) => Some(value.to_string()), + YamlValue::Number(value) => Some(value.to_string()), + YamlValue::Null => Some(String::new()), + _ => None, + } +} + +fn split_shell_words(value: &str) -> Vec { + let mut words = Vec::new(); + let mut current = String::new(); + let mut characters = value.chars().peekable(); + let mut quote = None; + + while let Some(character) = characters.next() { + match quote { + Some('\'') => { + if character == '\'' { + quote = None; + } else { + current.push(character); + } + } + Some('"') => { + if character == '"' { + quote = None; + } else if character == '\\' { + if let Some(next) = characters.next() { + current.push(next); + } + } else { + current.push(character); + } + } + _ if character.is_whitespace() => { + if !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + } + _ if character == '\'' || character == '"' => { + quote = Some(character); + } + _ if character == '\\' => { + if let Some(next) = characters.next() { + current.push(next); + } + } + _ => current.push(character), + } + } + + if let Some(quote) = quote { + current.insert(0, quote); + } + if !current.is_empty() { + words.push(current); + } + words +} + pub(super) fn default_service_image_name(spec: &ComposeSpec, args: &[String]) -> String { format!( "{}{}{}", diff --git a/cmd/devcontainer/src/runtime/compose/tests.rs b/cmd/devcontainer/src/runtime/compose/tests.rs index 28c52b9cb..b51f34914 100644 --- a/cmd/devcontainer/src/runtime/compose/tests.rs +++ b/cmd/devcontainer/src/runtime/compose/tests.rs @@ -361,6 +361,99 @@ fn metadata_override_file_can_pin_image_and_runtime_settings() { let _ = fs::remove_dir_all(root); } +#[test] +fn metadata_override_file_wraps_entrypoints_with_a_keepalive_entrypoint() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + let resolved = crate::runtime::context::ResolvedConfig { + workspace_folder: root.clone(), + config_file: root.join(".devcontainer.json"), + configuration: json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "overrideCommand": true, + "entrypoints": ["echo feature-entry", "echo feature-post-start"] + }), + }; + + let override_file = compose_metadata_override_file(&resolved, &[], "/workspace", None) + .expect("override result") + .expect("override path"); + let override_content = fs::read_to_string(&override_file).expect("override content"); + + assert!(override_content.contains("entrypoint:")); + assert!(override_content.contains("Container started")); + assert!(override_content.contains("echo feature-entry")); + assert!(override_content.contains("echo feature-post-start")); + + let _ = fs::remove_file(override_file); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn metadata_override_file_merges_config_entrypoint_into_wrapper_without_duplicates() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + let resolved = crate::runtime::context::ResolvedConfig { + workspace_folder: root.clone(), + config_file: root.join(".devcontainer.json"), + configuration: json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "entrypoint": "echo config-entrypoint" + }), + }; + + let override_file = compose_metadata_override_file(&resolved, &[], "/workspace", None) + .expect("override result") + .expect("override path"); + let override_content = fs::read_to_string(&override_file).expect("override content"); + let entrypoint_count = override_content + .lines() + .filter(|line| line.trim_start().starts_with("entrypoint:")) + .count(); + + assert_eq!(entrypoint_count, 1, "{override_content}"); + assert!(override_content.contains("Container started")); + assert!(override_content.contains("echo config-entrypoint")); + assert!(!override_content.contains("entrypoint: 'echo config-entrypoint'")); + + let _ = fs::remove_file(override_file); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn metadata_override_file_declares_named_volumes_top_level() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + let resolved = crate::runtime::context::ResolvedConfig { + workspace_folder: root.clone(), + config_file: root.join(".devcontainer.json"), + configuration: json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "mounts": [{ + "type": "volume", + "source": "feature-cache", + "target": "/cache", + "external": true + }] + }), + }; + + let override_file = compose_metadata_override_file(&resolved, &[], "/workspace", None) + .expect("override result") + .expect("override path"); + let override_content = fs::read_to_string(&override_file).expect("override content"); + + assert!(override_content.contains("\nvolumes:\n")); + assert!(override_content.contains("feature-cache:")); + assert!(override_content.contains("external: true")); + + let _ = fs::remove_file(override_file); + let _ = fs::remove_dir_all(root); +} + #[test] fn metadata_override_file_preserves_workspace_mount_options() { let root = unique_temp_dir("devcontainer-compose-test"); @@ -430,6 +523,96 @@ fn metadata_override_file_preserves_extended_mount_keys() { assert!(override_content.contains("propagation: 'rshared'")); assert!(override_content.contains("volume:")); assert!(override_content.contains("nocopy: true")); + assert!(override_content.contains("external: true")); + + let _ = fs::remove_file(override_file); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn metadata_override_file_allows_anonymous_cli_volume_mounts() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + let resolved = crate::runtime::context::ResolvedConfig { + workspace_folder: root.clone(), + config_file: root.join(".devcontainer.json"), + configuration: json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app" + }), + }; + + let override_file = compose_metadata_override_file( + &resolved, + &[ + "--mount".to_string(), + "type=volume,target=/cache".to_string(), + ], + "/workspaces/project", + None, + ) + .expect("override result") + .expect("override path"); + let override_content = fs::read_to_string(&override_file).expect("override content"); + + assert!(override_content.contains("type: 'volume'")); + assert!(override_content.contains("target: '/cache'")); + + let _ = fs::remove_file(override_file); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn metadata_override_file_appends_cli_mounts_after_config_mounts() { + let root = unique_temp_dir("devcontainer-compose-test"); + fs::create_dir_all(&root).expect("workspace root"); + let resolved = crate::runtime::context::ResolvedConfig { + workspace_folder: root.clone(), + config_file: root.join(".devcontainer.json"), + configuration: json!({ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "mounts": [{ + "type": "bind", + "source": "/tmp/config-src", + "target": "/tmp/config-dst" + }] + }), + }; + + let override_file = compose_metadata_override_file( + &resolved, + &[ + "--mount".to_string(), + "type=bind,source=/tmp/cli-src,target=/tmp/cli-dst,readonly".to_string(), + "--mount".to_string(), + "type=volume,source=cli-cache,target=/cli-cache".to_string(), + ], + "/workspaces/project", + None, + ) + .expect("override result") + .expect("override path"); + let override_content = fs::read_to_string(&override_file).expect("override content"); + + assert!(override_content.contains("source: '/tmp/config-src'")); + assert!(override_content.contains("target: '/tmp/config-dst'")); + assert!(override_content.contains("source: '/tmp/cli-src'")); + assert!(override_content.contains("target: '/tmp/cli-dst'")); + assert!(override_content.contains("read_only: true")); + assert!(override_content.contains("source: 'cli-cache'")); + assert!(override_content.contains("target: '/cli-cache'")); + + let config_position = override_content + .find("source: '/tmp/config-src'") + .expect("config mount"); + let cli_position = override_content + .find("source: '/tmp/cli-src'") + .expect("cli mount"); + assert!( + config_position < cli_position, + "expected config mounts before CLI mounts: {override_content}" + ); let _ = fs::remove_file(override_file); let _ = fs::remove_dir_all(root); diff --git a/cmd/devcontainer/src/runtime/container/discovery.rs b/cmd/devcontainer/src/runtime/container/discovery.rs index a03a60545..9fb057aab 100644 --- a/cmd/devcontainer/src/runtime/container/discovery.rs +++ b/cmd/devcontainer/src/runtime/container/discovery.rs @@ -121,7 +121,7 @@ fn create_compose_container( image_name: &str, remote_workspace_folder: &str, ) -> Result { - compose::up_service(resolved, args, remote_workspace_folder, image_name)?; + compose::up_service(resolved, args, remote_workspace_folder, image_name, false)?; let container_id = compose::resolve_container_id(resolved, args)? .ok_or_else(|| "Dev container not found.".to_string())?; Ok(UpContainer { @@ -138,7 +138,7 @@ fn refresh_compose_container( previous_container_id: &str, unchanged_mode: LifecycleMode, ) -> Result { - compose::up_service(resolved, args, remote_workspace_folder, image_name)?; + compose::up_service(resolved, args, remote_workspace_folder, image_name, true)?; let updated_container_id = compose::resolve_container_id(resolved, args)? .ok_or_else(|| "Dev container not found.".to_string())?; Ok(UpContainer { diff --git a/cmd/devcontainer/src/runtime/container/engine_run.rs b/cmd/devcontainer/src/runtime/container/engine_run.rs index d5fd94dd5..4ec74bfdd 100644 --- a/cmd/devcontainer/src/runtime/container/engine_run.rs +++ b/cmd/devcontainer/src/runtime/container/engine_run.rs @@ -69,10 +69,6 @@ pub(super) fn start_container( engine_args.push("--label".to_string()); engine_args.push(label); } - for mount in common::parse_option_values(args, "--mount") { - engine_args.push("--mount".to_string()); - engine_args.push(mount); - } if let Some(mounts) = resolved .configuration .get("mounts") @@ -83,6 +79,10 @@ pub(super) fn start_container( engine_args.push(mount); } } + for mount in crate::runtime::mounts::cli_mount_values(args)? { + engine_args.push("--mount".to_string()); + engine_args.push(mount); + } if let Some(run_args) = resolved .configuration .get("runArgs") diff --git a/cmd/devcontainer/src/runtime/context/workspace.rs b/cmd/devcontainer/src/runtime/context/workspace.rs index 494c557f0..0ab72991b 100644 --- a/cmd/devcontainer/src/runtime/context/workspace.rs +++ b/cmd/devcontainer/src/runtime/context/workspace.rs @@ -8,6 +8,7 @@ use std::process::Command; use serde_json::Value; use crate::commands::common; +use crate::runtime::compose; use super::{DerivedWorkspaceMount, ResolvedConfig}; @@ -45,6 +46,13 @@ pub(crate) fn remote_workspace_folder_for_args( resolved: &ResolvedConfig, args: &[String], ) -> String { + if compose::uses_compose_config(&resolved.configuration) + && resolved.configuration.get("workspaceFolder").is_none() + && resolved.configuration.get("workspaceMount").is_none() + { + return "/".to_string(); + } + resolved .configuration .get("workspaceFolder") diff --git a/cmd/devcontainer/src/runtime/engine.rs b/cmd/devcontainer/src/runtime/engine.rs index 0b4000c89..25e0f0d89 100644 --- a/cmd/devcontainer/src/runtime/engine.rs +++ b/cmd/devcontainer/src/runtime/engine.rs @@ -39,7 +39,7 @@ pub(crate) fn compose_request(args: &[String], compose_args: Vec) -> Pro let request_args = request.args.clone(); apply_buildkit_env(args, &request_args, &mut request); request - } else { + } else if default_compose_subcommand_available(args) { let mut args_with_subcommand = vec!["compose".to_string()]; args_with_subcommand.extend(compose_args); let mut request = @@ -47,6 +47,12 @@ pub(crate) fn compose_request(args: &[String], compose_args: Vec) -> Pro let request_args = request.args.clone(); apply_buildkit_env(args, &request_args, &mut request); request + } else { + let mut request = + common::runtime_process_request(args, "docker-compose".to_string(), compose_args, None); + let request_args = request.args.clone(); + apply_buildkit_env(args, &request_args, &mut request); + request } } @@ -71,6 +77,22 @@ fn engine_program(args: &[String]) -> String { common::parse_option_value(args, "--docker-path").unwrap_or_else(|| "docker".to_string()) } +fn default_compose_subcommand_available(args: &[String]) -> bool { + let request = common::runtime_process_request( + args, + engine_program(args), + vec![ + "compose".to_string(), + "version".to_string(), + "--short".to_string(), + ], + None, + ); + process_runner::run_process(&request) + .map(|result| result.status_code == 0) + .unwrap_or(false) +} + fn normalize_process_error(args: &[String], request: &ProcessRequest, error: io::Error) -> String { if error.kind() != io::ErrorKind::NotFound { return error.to_string(); @@ -80,6 +102,10 @@ fn normalize_process_error(args: &[String], request: &ProcessRequest, error: io: if common::parse_option_value(args, "--docker-compose-path") .as_deref() .is_some_and(|program| program == executable) + || Path::new(executable) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("docker-compose")) { return format!( "Container compose executable not found: {executable}. Verify --docker-compose-path or install the requested compose CLI." @@ -147,7 +173,9 @@ fn is_build_request(request_args: &[String]) -> bool { mod tests { use crate::process_runner::ProcessLogLevel; - use super::{compose_request, engine_request, is_build_request}; + use super::{ + compose_request, default_compose_subcommand_available, engine_request, is_build_request, + }; #[test] fn engine_request_applies_terminal_env_and_log_level() { @@ -214,4 +242,12 @@ mod tests { Some("0") ); } + + #[test] + fn default_compose_subcommand_probe_fails_without_engine() { + assert!(!default_compose_subcommand_available(&[ + "--docker-path".to_string(), + "/path/that/does/not/exist".to_string(), + ])); + } } diff --git a/cmd/devcontainer/src/runtime/mod.rs b/cmd/devcontainer/src/runtime/mod.rs index fefa3dafe..c37243609 100644 --- a/cmd/devcontainer/src/runtime/mod.rs +++ b/cmd/devcontainer/src/runtime/mod.rs @@ -55,6 +55,7 @@ pub fn run_build(args: &[String]) -> Result { } pub fn run_up(args: &[String]) -> Result { + let _ = mounts::cli_mount_values(args)?; let resolved = context::load_required_config(args)?; let feature_support = configuration::resolve_feature_support( args, diff --git a/cmd/devcontainer/src/runtime/mounts.rs b/cmd/devcontainer/src/runtime/mounts.rs index 2674eaf9e..fdacdeb44 100644 --- a/cmd/devcontainer/src/runtime/mounts.rs +++ b/cmd/devcontainer/src/runtime/mounts.rs @@ -2,6 +2,8 @@ use serde_json::{Map, Value}; +use crate::commands::common; + pub(crate) fn mount_option_target(mount: &str) -> Option { split_mount_options(mount).into_iter().find_map(|option| { for key in ["target", "destination", "dst"] { @@ -86,6 +88,56 @@ fn mount_object_to_engine_arg(entries: &Map) -> Option { (!options.is_empty()).then(|| options.join(",")) } +pub(crate) fn cli_mount_values(args: &[String]) -> Result, String> { + common::validate_option_values(args, &["--mount"])?; + let mounts = common::parse_option_values(args, "--mount"); + validate_cli_mount_values(&mounts)?; + Ok(mounts) +} + +pub(crate) fn validate_cli_mount_values(mounts: &[String]) -> Result<(), String> { + for mount in mounts { + validate_cli_mount_value(mount)?; + } + Ok(()) +} + +pub(crate) fn validate_cli_mount_value(mount: &str) -> Result<(), String> { + let mut is_volume_mount = false; + let mut has_mount_type = false; + let mut has_source = false; + let mut has_target = false; + + for option in split_mount_options(mount) { + if matches!(option.as_str(), "readonly" | "ro") { + continue; + } + + let Some((key, value)) = option.split_once('=') else { + return Err(invalid_cli_mount_error(mount)); + }; + let value = value.trim_matches('"'); + match key { + "type" if matches!(value, "bind" | "volume") => { + has_mount_type = true; + is_volume_mount = value == "volume"; + } + "type" => return Err(invalid_cli_mount_error(mount)), + "source" | "src" if !value.is_empty() => has_source = true, + "target" | "destination" | "dst" if !value.is_empty() => has_target = true, + _ => {} + } + } + + let requires_source = !is_volume_mount; + + if !has_mount_type || !has_target || (requires_source && !has_source) { + return Err(invalid_cli_mount_error(mount)); + } + + Ok(()) +} + fn mount_option_value(value: &Value) -> Option { match value { Value::Bool(boolean) => Some(boolean.to_string()), @@ -95,11 +147,19 @@ fn mount_option_value(value: &Value) -> Option { } } +fn invalid_cli_mount_error(mount: &str) -> String { + format!( + "Invalid value for option --mount: {mount}. Expected type=,target=[,...], with source= required for bind mounts" + ) +} + #[cfg(test)] mod tests { use serde_json::json; - use super::{mount_option_target, mount_value_to_engine_arg}; + use super::{ + cli_mount_values, mount_option_target, mount_value_to_engine_arg, validate_cli_mount_value, + }; #[test] fn mount_option_target_reads_quoted_targets() { @@ -141,4 +201,32 @@ mod tests { "type=volume,source=devcontainer-cache,target=/cache,consistency=delegated,external=true" ); } + + #[test] + fn validate_cli_mount_value_accepts_extended_scalar_options() { + validate_cli_mount_value( + "type=bind,source=/tmp/src,target=/tmp/dst,consistency=delegated,bind.propagation=rshared,readonly", + ) + .expect("valid mount"); + } + + #[test] + fn validate_cli_mount_value_accepts_anonymous_volume_mounts() { + validate_cli_mount_value("type=volume,target=/cache").expect("valid mount"); + } + + #[test] + fn validate_cli_mount_value_rejects_missing_required_keys() { + let error = + validate_cli_mount_value("type=bind,source=/tmp/src").expect_err("missing target"); + + assert!(error.contains("Invalid value for option --mount")); + } + + #[test] + fn cli_mount_values_require_option_values() { + let error = cli_mount_values(&["--mount".to_string()]).expect_err("missing mount value"); + + assert_eq!(error, "Missing value for option: --mount"); + } } diff --git a/cmd/devcontainer/tests/runtime_build_smoke/compose.rs b/cmd/devcontainer/tests/runtime_build_smoke/compose.rs index 3f393a62a..0ef45a505 100644 --- a/cmd/devcontainer/tests/runtime_build_smoke/compose.rs +++ b/cmd/devcontainer/tests/runtime_build_smoke/compose.rs @@ -4,6 +4,30 @@ use std::fs; use crate::support::runtime_harness::{write_devcontainer_config, RuntimeHarness}; +fn generated_build_override_contents(harness: &RuntimeHarness) -> String { + let log = harness.read_compose_file_log(); + let mut capture = false; + let mut content = String::new(); + for line in log.lines() { + if let Some(path) = line.strip_prefix("BEGIN ") { + capture = path.contains("devcontainer-compose-build-override"); + continue; + } + if line.starts_with("END ") { + if capture { + break; + } + capture = false; + continue; + } + if capture { + content.push_str(line); + content.push('\n'); + } + } + content +} + #[test] fn build_uses_compose_for_compose_build_services() { let harness = RuntimeHarness::new(); @@ -82,6 +106,56 @@ fn build_returns_default_compose_image_name_for_build_only_services() { assert_eq!(payload["imageName"], "workspace_devcontainer-app"); } +#[test] +fn build_accepts_cache_from_for_compose_builds() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join(".devcontainer").join("Dockerfile"), + "FROM scratch\n", + ) + .expect("dockerfile"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "version: '3.8'\nservices:\n app:\n image: example/native-compose:test\n build:\n context: .\n dockerfile: Dockerfile\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "build", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "--cache-from", + "ghcr.io/example/cache:one", + "--cache-from", + "ghcr.io/example/cache:two", + ], + &[], + ); + + assert!(output.status.success(), "{output:?}"); + let invocations = harness.read_invocations(); + assert!(invocations.contains("compose --project-name workspace_devcontainer -f ")); + let override_content = generated_build_override_contents(&harness); + assert!( + override_content.starts_with("version: '3.8'\n"), + "{override_content}" + ); + assert!(override_content.contains("build:")); + assert!(override_content.contains("cache_from:")); + assert!(override_content.contains("ghcr.io/example/cache:one")); + assert!(override_content.contains("ghcr.io/example/cache:two")); +} + #[test] fn build_passes_no_cache_to_compose_builds() { let harness = RuntimeHarness::new(); @@ -208,3 +282,131 @@ fn build_rejects_cache_to_for_compose_builds() { "--cache-to not supported for compose builds." ); } + +#[test] +fn build_rejects_output_for_compose_builds() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join(".devcontainer").join("Dockerfile"), + "FROM scratch\n", + ) + .expect("dockerfile"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "services:\n app:\n image: example/native-compose:test\n build:\n context: .\n dockerfile: Dockerfile\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "build", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "--output", + "type=docker", + ], + &[], + ); + + assert!(!output.status.success(), "{output:?}"); + assert_eq!( + String::from_utf8(output.stderr) + .expect("utf8 stderr") + .trim(), + "--output not supported." + ); +} + +#[test] +fn build_rejects_platform_for_compose_builds() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join(".devcontainer").join("Dockerfile"), + "FROM scratch\n", + ) + .expect("dockerfile"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "services:\n app:\n image: example/native-compose:test\n build:\n context: .\n dockerfile: Dockerfile\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "build", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "--platform", + "linux/amd64", + ], + &[], + ); + + assert!(!output.status.success(), "{output:?}"); + assert_eq!( + String::from_utf8(output.stderr) + .expect("utf8 stderr") + .trim(), + "--platform or --push not supported." + ); +} + +#[test] +fn build_rejects_push_for_compose_builds() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join(".devcontainer").join("Dockerfile"), + "FROM scratch\n", + ) + .expect("dockerfile"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "services:\n app:\n image: example/native-compose:test\n build:\n context: .\n dockerfile: Dockerfile\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "build", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "--push", + ], + &[], + ); + + assert!(!output.status.success(), "{output:?}"); + assert_eq!( + String::from_utf8(output.stderr) + .expect("utf8 stderr") + .trim(), + "--platform or --push not supported." + ); +} diff --git a/cmd/devcontainer/tests/runtime_container_smoke/basic.rs b/cmd/devcontainer/tests/runtime_container_smoke/basic.rs index 6811b8972..4212c1d18 100644 --- a/cmd/devcontainer/tests/runtime_container_smoke/basic.rs +++ b/cmd/devcontainer/tests/runtime_container_smoke/basic.rs @@ -175,6 +175,80 @@ fn up_applies_feature_runtime_metadata_to_container_creation() { assert!(exec_log.contains("/bin/sh -lc echo feature-ready")); } +#[test] +fn up_emits_config_mounts_before_cli_mounts() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(&workspace).expect("workspace dir"); + write_devcontainer_config( + &workspace, + "{\n \"image\": \"alpine:3.20\",\n \"mounts\": [{\n \"type\": \"bind\",\n \"source\": \"/tmp/config-src\",\n \"target\": \"/tmp/config-dst\"\n }]\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "--mount", + "type=volume,source=cli-cache,target=/cli-cache", + ], + &[("FAKE_PODMAN_PS_DISABLE_DEFAULT", "1")], + ); + + assert!(output.status.success(), "{output:?}"); + + let invocations = harness.read_invocations(); + let config_mount = "--mount type=bind,source=/tmp/config-src,target=/tmp/config-dst"; + let cli_mount = "--mount type=volume,source=cli-cache,target=/cli-cache"; + let config_position = invocations.find(config_mount).expect("config mount"); + let cli_position = invocations.find(cli_mount).expect("cli mount"); + + assert!( + config_position < cli_position, + "expected config mounts before CLI mounts: {invocations}" + ); +} + +#[test] +fn up_rejects_invalid_cli_mount_before_engine_invocation() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(&workspace).expect("workspace dir"); + write_devcontainer_config(&workspace, "{\n \"image\": \"alpine:3.20\"\n}\n"); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "--mount", + "invalid-mount", + ], + &[("FAKE_PODMAN_PS_DISABLE_DEFAULT", "1")], + ); + + assert!(!output.status.success(), "{output:?}"); + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); + assert!( + stderr.contains("Invalid value for option --mount"), + "{stderr}" + ); + + let invocation_log = harness.log_dir.join("invocations.log"); + assert!( + !invocation_log.exists(), + "unexpected engine invocation log: {}", + fs::read_to_string(&invocation_log).unwrap_or_default() + ); +} + #[test] fn up_adds_gpu_flags_when_required_and_gpu_availability_is_all() { let harness = RuntimeHarness::new(); diff --git a/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs b/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs index f4f4ebb15..fb3fb987c 100644 --- a/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs +++ b/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs @@ -4,6 +4,30 @@ use std::fs; use crate::support::runtime_harness::{write_devcontainer_config, RuntimeHarness}; +fn generated_override_contents(harness: &RuntimeHarness) -> String { + let log = harness.read_compose_file_log(); + let mut capture = false; + let mut content = String::new(); + for line in log.lines() { + if let Some(path) = line.strip_prefix("BEGIN ") { + capture = path.contains("devcontainer-compose-override"); + continue; + } + if line.starts_with("END ") { + if capture { + break; + } + capture = false; + continue; + } + if capture { + content.push_str(line); + content.push('\n'); + } + } + content +} + #[test] fn up_starts_compose_services_and_exec_uses_compose_container_lookup() { let harness = RuntimeHarness::new(); @@ -58,7 +82,8 @@ fn up_starts_compose_services_and_exec_uses_compose_container_lookup() { let invocations = harness.read_invocations(); assert!(invocations.contains("compose --project-name workspace_devcontainer -f ")); - assert!(invocations.contains(" up -d app")); + assert!(invocations.contains(" up -d")); + assert!(!invocations.contains(" up -d app")); assert!(invocations.contains(" ps -q app")); assert!(invocations.contains( "exec --workdir /workspace --user vscode fake-compose-container-id /bin/echo hello-from-compose" @@ -68,6 +93,105 @@ fn up_starts_compose_services_and_exec_uses_compose_container_lookup() { assert!(exec_log.contains("/bin/sh -lc echo ready")); } +#[test] +fn up_generated_override_preserves_compose_version_prefix() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "version: '3.8'\nservices:\n app:\n image: alpine:3.20\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + ], + &[], + ); + + assert!(output.status.success(), "{output:?}"); + let override_content = generated_override_contents(&harness); + assert!( + override_content.starts_with("version: '3.8'\n"), + "{override_content}" + ); +} + +#[test] +fn up_uses_root_remote_workspace_folder_when_compose_workspace_folder_is_omitted() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + ], + &[], + ); + + assert!(output.status.success(), "{output:?}"); + let payload = harness.parse_stdout_json(&output); + assert_eq!(payload["remoteWorkspaceFolder"], "/"); +} + +#[test] +fn up_honors_run_services_and_includes_the_primary_service() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n worker:\n image: alpine:3.20\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\",\n \"runServices\": [\"worker\"]\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + ], + &[], + ); + + assert!(output.status.success(), "{output:?}"); + let invocations = harness.read_invocations(); + assert!(invocations.contains(" up -d worker app")); +} + #[test] fn up_re_resolves_recreated_compose_container_ids() { let harness = RuntimeHarness::new(); @@ -251,7 +375,8 @@ fn up_resumes_stopped_compose_services_without_rerunning_create_hooks() { let invocations = harness.read_invocations(); assert!(invocations.contains(" ps -q app")); assert!(invocations.contains(" ps -q -a app")); - assert!(invocations.contains(" up -d app")); + assert!(invocations.contains(" up -d --no-recreate")); + assert!(!invocations.contains(" up -d app")); let exec_log = harness.read_exec_log(); assert!(!exec_log.contains("/bin/sh -lc echo on-create")); @@ -260,3 +385,68 @@ fn up_resumes_stopped_compose_services_without_rerunning_create_hooks() { assert!(exec_log.contains("/bin/sh -lc echo post-start")); assert!(exec_log.contains("/bin/sh -lc echo post-attach")); } + +#[test] +fn up_reuses_existing_compose_container_with_no_recreate() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + ], + &[("FAKE_PODMAN_COMPOSE_PS_OUTPUT", "fake-compose-container-id")], + ); + + assert!(output.status.success(), "{output:?}"); + let invocations = harness.read_invocations(); + assert!(invocations.contains(" up -d --no-recreate")); +} + +#[test] +fn up_expect_existing_compose_container_uses_no_recreate() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + "--expect-existing-container", + ], + &[("FAKE_PODMAN_COMPOSE_PS_OUTPUT", "fake-compose-container-id")], + ); + + assert!(output.status.success(), "{output:?}"); + let invocations = harness.read_invocations(); + assert!(invocations.contains(" up -d --no-recreate")); +} diff --git a/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs b/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs index 1b8d691ed..710f4df87 100644 --- a/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs +++ b/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs @@ -1,9 +1,21 @@ //! Runtime container smoke tests for compose project-name behavior. use std::fs; +use std::path::Path; use crate::support::runtime_harness::{write_devcontainer_config, RuntimeHarness}; +fn write_executable_script(path: &Path, body: &str) { + fs::write(path, body).expect("script"); + let mut permissions = fs::metadata(path).expect("script metadata").permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o755); + } + fs::set_permissions(path, permissions).expect("script permissions"); +} + #[test] fn up_uses_custom_compose_project_name_from_compose_file() { let harness = RuntimeHarness::new(); @@ -116,3 +128,148 @@ fn up_expands_plain_dollar_compose_project_names() { let invocations = harness.read_invocations(); assert!(invocations.contains("compose --project-name fromenv_project -f ")); } + +#[test] +fn up_uses_default_compose_files_when_docker_compose_file_array_is_empty() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose"); + fs::write( + workspace.join("docker-compose.override.yml"), + "services:\n app:\n environment:\n EXTRA: override\n", + ) + .expect("compose override"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": [],\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run_in_dir( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + ], + &[], + Some(&workspace), + ); + + assert!(output.status.success(), "{output:?}"); + let invocations = harness.read_invocations(); + let compose_file = workspace + .join("docker-compose.yml") + .canonicalize() + .unwrap_or_else(|_| workspace.join("docker-compose.yml")); + let override_file = workspace + .join("docker-compose.override.yml") + .canonicalize() + .unwrap_or_else(|_| workspace.join("docker-compose.override.yml")); + assert!( + invocations.contains(&format!( + " -f {} -f {} ", + compose_file.display(), + override_file.display() + )), + "{invocations}" + ); +} + +#[test] +fn up_resolves_compose_file_env_paths_relative_to_workspace_when_array_is_empty() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let compose_dir = workspace.join("relative-compose"); + fs::create_dir_all(&compose_dir).expect("compose dir"); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + compose_dir.join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": [],\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run_in_dir( + &[ + "up", + "--docker-path", + fake_podman.as_str(), + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + ], + &[("COMPOSE_FILE", "relative-compose/docker-compose.yml")], + Some(&workspace), + ); + + assert!(output.status.success(), "{output:?}"); + let invocations = harness.read_invocations(); + let compose_file = compose_dir + .join("docker-compose.yml") + .canonicalize() + .unwrap_or_else(|_| compose_dir.join("docker-compose.yml")); + assert!( + invocations.contains(&format!(" -f {} ", compose_file.display())), + "{invocations}" + ); +} + +#[test] +fn up_falls_back_to_docker_compose_when_docker_compose_subcommand_is_unavailable() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let docker_wrapper = harness.root.join("docker"); + let docker_compose_wrapper = harness.root.join("docker-compose"); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "services:\n app:\n image: alpine:3.20\n", + ) + .expect("compose"); + write_devcontainer_config( + &workspace, + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", + ); + write_executable_script( + &docker_wrapper, + &format!( + "#!/bin/sh\nif [ \"${{1:-}}\" = \"compose\" ]; then\n exit 1\nfi\nexec \"{}\" \"$@\"\n", + harness.fake_podman.display() + ), + ); + write_executable_script( + &docker_compose_wrapper, + &format!( + "#!/bin/sh\nexec \"{}\" compose \"$@\"\n", + harness.fake_podman.display() + ), + ); + let path = format!( + "{}:{}", + harness.root.display(), + std::env::var("PATH").unwrap_or_default() + ); + + let output = harness.run( + &[ + "up", + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + ], + &[("PATH", path.as_str())], + ); + + assert!(output.status.success(), "{output:?}"); + let invocations = harness.read_invocations(); + assert!(invocations.contains("compose --project-name workspace_devcontainer -f ")); +} diff --git a/cmd/devcontainer/tests/support/runtime_harness.rs b/cmd/devcontainer/tests/support/runtime_harness.rs index 52f59b18a..110b59017 100644 --- a/cmd/devcontainer/tests/support/runtime_harness.rs +++ b/cmd/devcontainer/tests/support/runtime_harness.rs @@ -89,6 +89,11 @@ impl RuntimeHarness { fs::read_to_string(self.log_dir.join("exec-argv.log")).expect("exec argv log") } + pub fn read_compose_file_log(&self) -> String { + fs::read_to_string(self.log_dir.join("compose-file-contents.log")) + .expect("compose file log") + } + pub fn parse_stdout_json(&self, output: &Output) -> Value { serde_json::from_slice(&output.stdout).expect("json payload") } diff --git a/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs b/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs index c59ca642a..394cd23ee 100644 --- a/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs +++ b/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs @@ -39,6 +39,22 @@ ${2:-}" shift || true case "$SUBCOMMAND" in build) + compose_file_contents="$LOG_DIR/compose-file-contents.log" + : > "$compose_file_contents" + if [ -n "$COMPOSE_FILES" ]; then + old_ifs="${IFS- }" + IFS=' +' + for compose_file in $COMPOSE_FILES; do + [ -f "$compose_file" ] || continue + { + printf '%s\n' "BEGIN $compose_file" + cat "$compose_file" + printf '%s\n' "END $compose_file" + } >> "$compose_file_contents" + done + IFS="$old_ifs" + fi exit 0 ;; version) @@ -52,13 +68,20 @@ ${2:-}" up) : > "$LOG_DIR/compose-up-called" compose_labels_file="$LOG_DIR/compose-last-run-labels" + compose_file_contents="$LOG_DIR/compose-file-contents.log" : > "$compose_labels_file" + : > "$compose_file_contents" if [ -n "$COMPOSE_FILES" ]; then old_ifs="${IFS- }" IFS=' ' for compose_file in $COMPOSE_FILES; do [ -f "$compose_file" ] || continue + { + printf '%s\n' "BEGIN $compose_file" + cat "$compose_file" + printf '%s\n' "END $compose_file" + } >> "$compose_file_contents" while IFS= read -r line; do case "$line" in *"- '"*"'"|*"- \""*"\""|*" - "*"="*) diff --git a/docs/upstream/compose-parity.md b/docs/upstream/compose-parity.md new file mode 100644 index 000000000..41b397c6b --- /dev/null +++ b/docs/upstream/compose-parity.md @@ -0,0 +1,40 @@ +# Compose Parity Inventory + +Pinned upstream CLI commit: `39685cf1aa58b5b11e90085bd32562fad61f4103` + +This is a semantic parity note for the native Rust Compose path. It complements the generated command-matrix inventory in `docs/upstream/parity-inventory.md`, which only records static source references. + +## Matched on this branch + +- Compose CLI discovery now prefers `docker compose version --short` and falls back to `docker-compose version --short`. +- `dockerComposeFile: []` now follows the upstream default-file search shape: `COMPOSE_FILE`, workspace `.env` `COMPOSE_FILE`, then `docker-compose.yml` plus optional `docker-compose.override.yml`. +- Compose `up` now matches upstream defaults more closely: + - start all services when `runServices` is unset + - append the primary service when `runServices` omits it + - use `--no-recreate` when reusing or expecting an existing container + - default the remote workspace folder to `/` when Compose config omits `workspaceFolder` +- Compose `up --mount ...` is now threaded into generated override files, with the same config-before-CLI ordering as the single-container engine path. +- Compose start overrides now preserve the first-file `version:` prefix, emit the keepalive wrapper entrypoint, merge feature/config `entrypoints`, honor `overrideCommand`, and declare named volumes at the top level. +- Compose build now accepts `--cache-from` by generating a build override file, and explicitly rejects `--cache-to`, `--output`, `--platform`, and `--push` in the same areas upstream rejects them. + +## Remaining gaps + +- Project-name `.env` lookup still diverges: + - native code reads `.env` next to the first compose file in `cmd/devcontainer/src/runtime/compose/project.rs` + - upstream reads `.env` from the caller working directory / workspace root when `dockerComposeFile` is empty + - current native coverage still pins the old behavior in `cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs` +- The native Compose path still derives service metadata from raw YAML files instead of `docker compose config`. + - upstream resolves the merged Compose model first + - native behavior can still diverge for profile expansion, env interpolation, multi-file merge edge cases, and custom-tag handling +- The keepalive wrapper preserves Compose-service `entrypoint`/`command`, but it does not inspect image `Entrypoint`/`Cmd` defaults the way upstream does. + - configs that rely on image defaults rather than service-level overrides can still start differently +- Compose build still rejects `--label`. + - upstream accepts the flag on `build` + - native Compose build does not yet thread labels into a Compose build override + +## Coverage added here + +- `cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs` +- `cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs` +- `cmd/devcontainer/tests/runtime_build_smoke/compose.rs` +- `cmd/devcontainer/src/runtime/compose/tests.rs` diff --git a/docs/upstream/parity-inventory.json b/docs/upstream/parity-inventory.json index 570ed7da2..9e2940df2 100644 --- a/docs/upstream/parity-inventory.json +++ b/docs/upstream/parity-inventory.json @@ -49,7 +49,7 @@ "sourceReferenced": true, "evidence": [ "cmd/devcontainer/src/runtime/build.rs", - "cmd/devcontainer/src/runtime/compose/args.rs" + "cmd/devcontainer/src/runtime/compose/override_file.rs" ] }, { @@ -222,7 +222,8 @@ "cmd/devcontainer/src/commands/configuration/read.rs", "cmd/devcontainer/src/runtime/container/engine_run.rs", "cmd/devcontainer/src/runtime/context/workspace.rs", - "cmd/devcontainer/src/runtime/exec.rs" + "cmd/devcontainer/src/runtime/exec.rs", + "cmd/devcontainer/src/runtime/mounts.rs" ] }, { @@ -575,7 +576,7 @@ "sourceReferenced": true, "evidence": [ "cmd/devcontainer/src/runtime/build.rs", - "cmd/devcontainer/src/runtime/compose/args.rs" + "cmd/devcontainer/src/runtime/compose/override_file.rs" ] }, { @@ -685,7 +686,8 @@ "name": "output", "sourceReferenced": true, "evidence": [ - "cmd/devcontainer/src/commands/configuration/upgrade.rs" + "cmd/devcontainer/src/commands/configuration/upgrade.rs", + "cmd/devcontainer/src/runtime/compose/args.rs" ] }, { @@ -702,6 +704,7 @@ "sourceReferenced": true, "evidence": [ "cmd/devcontainer/src/runtime/build.rs", + "cmd/devcontainer/src/runtime/compose/args.rs", "cmd/devcontainer/src/runtime/compose/mod.rs" ] },