From 1e3123f0b21314f1d14d709b8576938bc27862f3 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Wed, 15 Apr 2026 20:23:01 +0200 Subject: [PATCH 01/15] devcontainer-context.sh shell script to adapt --- devcontainer-context.sh | 417 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 devcontainer-context.sh diff --git a/devcontainer-context.sh b/devcontainer-context.sh new file mode 100644 index 000000000..748568822 --- /dev/null +++ b/devcontainer-context.sh @@ -0,0 +1,417 @@ +#!/bin/bash +set -e + +# Wrapper script to run commands inside the dev container + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +require_command() { + local command_name="$1" + local install_hint="$2" + + if ! command -v "$command_name" >/dev/null 2>&1; then + fail "$install_hint" + fi +} + +run_devcontainer() { + local exit_code=0 + + if devcontainer "$@"; then + return 0 + else + exit_code=$? + fi + + fail "devcontainer $1 failed with exit code $exit_code" +} + +usage() { + cat <; + close $handle; + my $parser = JSON::PP->new->relaxed; + my $parsed = $parser->decode($content); + print JSON::PP->new->canonical->encode($parsed); + ' "$DEVCONTAINER_CONFIG" +} + +resolve_compose_file_path() { + local config_dir="$1" + local compose_path="$2" + + case "$compose_path" in + /*) + printf '%s\n' "$compose_path" + ;; + *) + printf '%s/%s\n' "$config_dir" "$compose_path" + ;; + esac +} + +detect_devcontainer_type() { + local config_dir + local compose_file + local compose_kind + local normalized_config + local workspace_folder + + config_dir="$(dirname "$DEVCONTAINER_CONFIG")" + + normalized_config="$(normalize_devcontainer_config)" || fail "Unable to parse $DEVCONTAINER_CONFIG" + + compose_kind="$(jq -r ' + if has("dockerComposeFile") then + .dockerComposeFile | type + else + "null" + end + ' <<< "$normalized_config")" + + case "$compose_kind" in + null) + DEVCONTAINER_KIND="regular" + ;; + string) + DEVCONTAINER_KIND="compose" + COMPOSE_FILES=("$(jq -r '.dockerComposeFile' <<< "$normalized_config")") + ;; + array) + DEVCONTAINER_KIND="compose" + if ! jq -e '.dockerComposeFile | all(.[]?; type == "string")' >/dev/null <<< "$normalized_config"; then + fail "dockerComposeFile must be a string or an array of strings" + fi + mapfile -t COMPOSE_FILES < <(jq -r '.dockerComposeFile[]' <<< "$normalized_config") + ;; + *) + fail "dockerComposeFile must be a string or an array of strings" + ;; + esac + + workspace_folder="$(jq -r '.workspaceFolder // empty' <<< "$normalized_config")" + if [[ -z "$workspace_folder" ]]; then + fail "workspaceFolder must be set in $DEVCONTAINER_CONFIG" + fi + CONTAINER_WORKSPACE_FOLDER="$workspace_folder" + + if [[ "$DEVCONTAINER_KIND" == "compose" ]]; then + if [[ ${#COMPOSE_FILES[@]} -eq 0 ]]; then + fail "No compose files were found in $DEVCONTAINER_CONFIG" + fi + + for compose_file in "${!COMPOSE_FILES[@]}"; do + COMPOSE_FILES[compose_file]="$(resolve_compose_file_path "$config_dir" "${COMPOSE_FILES[compose_file]}")" + done + fi +} + +validate_compose_files() { + local compose_file + + for compose_file in "${COMPOSE_FILES[@]}"; do + if [[ ! -f "$compose_file" ]]; then + fail "Compose file '$compose_file' configured in $DEVCONTAINER_CONFIG was not found" + fi + done +} + +resolve_host_git_config() { + if [[ -f "$HOME/.gitconfig" ]]; then + HOST_GIT_CONFIG="$HOME/.gitconfig" + fi +} + +ensure_host_config_dir() { + mkdir -p "$HOST_CONFIG_DIR" +} + +resolve_local_volume_name() { + LOCAL_VOLUME_NAME="${PROJECT_NAME}_root_local" +} + +ensure_local_volume() { + if [[ -z "$LOCAL_VOLUME_NAME" ]]; then + fail "Local volume name was not resolved" + fi + + podman volume create \ + --ignore \ + --label "devcontainer.local_folder=$WORKSPACE_FOLDER" \ + --label "devcontainer.config_file=$DEVCONTAINER_CONFIG" \ + --label "devcontainer.volume_role=root_local" \ + "$LOCAL_VOLUME_NAME" >/dev/null +} + +is_container_running() { + podman ps \ + --quiet \ + --filter "label=devcontainer.local_folder=$WORKSPACE_FOLDER" \ + --filter "label=devcontainer.config_file=$DEVCONTAINER_CONFIG" +} + +get_existing_container_id() { + podman ps \ + --all \ + --quiet \ + --filter "label=devcontainer.local_folder=$WORKSPACE_FOLDER" \ + --filter "label=devcontainer.config_file=$DEVCONTAINER_CONFIG" +} + +container_has_git_config_mount() { + local container_id="$1" + + if [[ -z "$HOST_GIT_CONFIG" || -z "$container_id" ]]; then + return 1 + fi + + podman inspect "$container_id" \ + --format '{{range .Mounts}}{{println .Destination}}{{end}}' | grep -Fxq "$CONTAINER_GIT_CONFIG" +} + +container_has_host_config_mount() { + local container_id="$1" + + if [[ -z "$container_id" ]]; then + return 1 + fi + + podman inspect "$container_id" \ + --format '{{range .Mounts}}{{println .Type .Destination}}{{end}}' | grep -Fxq "bind $CONTAINER_CONFIG_DIR" +} + +container_has_root_local_volume_mount() { + local container_id="$1" + + if [[ -z "$container_id" ]]; then + return 1 + fi + + podman inspect "$container_id" \ + --format '{{range .Mounts}}{{println .Type .Destination}}{{end}}' | grep -Fxq "volume $CONTAINER_LOCAL_DIR" +} + +warn_if_git_config_mount_missing() { + local container_id + + if [[ -z "$HOST_GIT_CONFIG" ]]; then + return + fi + + container_id="$(get_existing_container_id | head -n 1)" + if [[ -n "$container_id" ]] && ! container_has_git_config_mount "$container_id"; then + echo "Notice: existing dev container was created without the host git config mount. Re-run with --reset to recreate it with git config sharing enabled." >&2 + fi +} + +warn_if_host_config_mount_missing() { + local container_id + + container_id="$(get_existing_container_id | head -n 1)" + if [[ -n "$container_id" ]] && ! container_has_host_config_mount "$container_id"; then + echo "Notice: existing dev container was created without the host ~/.config bind mount. Re-run with --reset to recreate it with shared config enabled." >&2 + fi +} + +warn_if_root_local_volume_mount_missing() { + local container_id + + container_id="$(get_existing_container_id | head -n 1)" + if [[ -n "$container_id" ]] && ! container_has_root_local_volume_mount "$container_id"; then + echo "Notice: existing dev container was created without the persistent /root/.local volume. Re-run with --reset to recreate it with local state persistence enabled." >&2 + fi +} + +get_running_container_id() { + is_container_running | head -n 1 +} + +exec_in_devcontainer() { + local container_id + local exec_args=() + + container_id="$(get_running_container_id)" + if [[ -z "$container_id" ]]; then + fail "No running dev container found for $WORKSPACE_FOLDER" + fi + + exec_args=(exec) + if [[ -t 0 && -t 1 ]]; then + exec_args+=(--interactive --tty --detach-keys "$PODMAN_DETACH_KEYS") + elif [[ -t 0 ]]; then + exec_args+=(--interactive) + fi + + exec_args+=(--workdir "$CONTAINER_WORKSPACE_FOLDER") + exec_args+=(-e "SHELL=$CONTAINER_SHELL") + + if [[ -n "$HOST_GIT_CONFIG" ]]; then + exec_args+=(-e "GIT_CONFIG_GLOBAL=$CONTAINER_GIT_CONFIG") + fi + + exec podman "${exec_args[@]}" "$container_id" "$@" +} + +compose_reset() { + local compose_args=() + local compose_file + + echo "Tearing down dev container..." + for compose_file in "${COMPOSE_FILES[@]}"; do + compose_args+=( -f "$compose_file" ) + done + + podman compose "${compose_args[@]}" --project-name "$PROJECT_NAME" down -v --remove-orphans +} + +start_devcontainer() { + local up_args=(up) + + up_args+=("${DEVCONTAINER_UP_ARGS[@]}") + + if [[ "$1" == "reset" && "$DEVCONTAINER_KIND" != "compose" ]]; then + up_args+=(--remove-existing-container) + fi + + echo "Starting dev container..." + run_devcontainer "${up_args[@]}" + run_devcontainer run-user-commands "${DEVCONTAINER_RUNTIME_ARGS[@]}" +} + +# Parse flags +while [[ $# -gt 0 ]]; do + case "$1" in + --help) + usage + exit 0 + ;; + --reset) + RESET=true + shift + ;; + -*) + echo "Error: Unknown option '$1'" >&2 + echo "Use --help for usage information" >&2 + exit 1 + ;; + *) + break + ;; + esac +done + +WORKSPACE_FOLDER="$(pwd -P)" +WORKSPACE_NAME="$(basename "$WORKSPACE_FOLDER")" +# Project name used by devcontainer CLI: _devcontainer +PROJECT_NAME="${WORKSPACE_NAME}_devcontainer" + +# Check dependencies +require_command "devcontainer" "The Dev Container CLI ('devcontainer') is not installed or not in PATH. Install it from https://github.com/devcontainers/cli or with 'npm install -g @devcontainers/cli'." +require_command "jq" "jq is required to inspect the dev container configuration" +require_command "perl" "perl is required to parse JSONC dev container configurations" +require_command "podman" "podman is not installed or not in PATH" + +resolve_devcontainer_config +detect_devcontainer_type +resolve_host_git_config +ensure_host_config_dir +resolve_local_volume_name +ensure_local_volume + +DOCKER_PATH="$(command -v podman)" +DEVCONTAINER_RUNTIME_ARGS=(--docker-path "$DOCKER_PATH" --workspace-folder "$WORKSPACE_FOLDER" --config "$DEVCONTAINER_CONFIG") +DEVCONTAINER_RUNTIME_ARGS+=(--remote-env "SHELL=$CONTAINER_SHELL") + +if [[ "$DEVCONTAINER_KIND" == "compose" ]]; then + # Export UID for compose file interpolation (bash special variable, not exported by default) + export UID + validate_compose_files + require_command "podman-compose" "podman-compose is required for compose-based dev containers" + COMPOSE_PATH="$(command -v podman-compose)" + DEVCONTAINER_RUNTIME_ARGS+=(--docker-compose-path "$COMPOSE_PATH") +fi + +if [[ -n "$HOST_GIT_CONFIG" ]]; then + DEVCONTAINER_RUNTIME_ARGS+=(--remote-env "GIT_CONFIG_GLOBAL=$CONTAINER_GIT_CONFIG") +fi + +DEVCONTAINER_UP_ARGS=("${DEVCONTAINER_RUNTIME_ARGS[@]}") +DEVCONTAINER_UP_ARGS+=(--mount "type=bind,source=$HOST_CONFIG_DIR,target=$CONTAINER_CONFIG_DIR") +DEVCONTAINER_UP_ARGS+=(--mount "type=volume,source=$LOCAL_VOLUME_NAME,target=$CONTAINER_LOCAL_DIR") + +if [[ -n "$HOST_GIT_CONFIG" ]]; then + DEVCONTAINER_UP_ARGS+=(--mount "type=bind,source=$HOST_GIT_CONFIG,target=$CONTAINER_GIT_CONFIG") +fi + +if ! $RESET; then + warn_if_host_config_mount_missing + warn_if_root_local_volume_mount_missing + warn_if_git_config_mount_missing +fi + +if $RESET; then + if [[ "$DEVCONTAINER_KIND" == "compose" ]]; then + compose_reset + fi + start_devcontainer reset +elif [[ -z "$(is_container_running)" ]]; then + start_devcontainer start +fi + +# If command provided, exec it +if [[ $# -gt 0 ]]; then + exec_in_devcontainer "$@" +fi From 336b8b20d02168b3ba5b3ff61266fa1ca382bac5 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Wed, 15 Apr 2026 21:49:40 +0200 Subject: [PATCH 02/15] Add mount passthrough regression tests --- cmd/devcontainer/src/runtime/compose/tests.rs | 56 ++++++++++++++ .../tests/runtime_container_smoke/basic.rs | 73 +++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/cmd/devcontainer/src/runtime/compose/tests.rs b/cmd/devcontainer/src/runtime/compose/tests.rs index 28c52b9cb..2dd71a0b5 100644 --- a/cmd/devcontainer/src/runtime/compose/tests.rs +++ b/cmd/devcontainer/src/runtime/compose/tests.rs @@ -435,6 +435,62 @@ fn metadata_override_file_preserves_extended_mount_keys() { 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); +} + #[test] fn metadata_override_file_does_not_promote_remote_user_to_service_user() { let root = unique_temp_dir("devcontainer-compose-test"); diff --git a/cmd/devcontainer/tests/runtime_container_smoke/basic.rs b/cmd/devcontainer/tests/runtime_container_smoke/basic.rs index 6811b8972..aa8d24b02 100644 --- a/cmd/devcontainer/tests/runtime_container_smoke/basic.rs +++ b/cmd/devcontainer/tests/runtime_container_smoke/basic.rs @@ -175,6 +175,79 @@ 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 invocations = harness.read_invocations(); + assert!( + !invocations.contains("run "), + "unexpected engine run: {invocations}" + ); +} + #[test] fn up_adds_gpu_flags_when_required_and_gpu_availability_is_all() { let harness = RuntimeHarness::new(); From 414fbb69782b5bf86c524cda86e7827805987e55 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Wed, 15 Apr 2026 21:55:31 +0200 Subject: [PATCH 03/15] Normalize up mount handling across engine and compose --- .../src/runtime/compose/override_file.rs | 2 +- .../src/runtime/compose/override_mounts.rs | 31 ++++++-- .../src/runtime/container/engine_run.rs | 8 +- cmd/devcontainer/src/runtime/mod.rs | 1 + cmd/devcontainer/src/runtime/mounts.rs | 79 ++++++++++++++++++- .../tests/runtime_container_smoke/basic.rs | 7 +- 6 files changed, 111 insertions(+), 17 deletions(-) diff --git a/cmd/devcontainer/src/runtime/compose/override_file.rs b/cmd/devcontainer/src/runtime/compose/override_file.rs index 4c14ebb5a..63b1d937b 100644 --- a/cmd/devcontainer/src/runtime/compose/override_file.rs +++ b/cmd/devcontainer/src/runtime/compose/override_file.rs @@ -68,7 +68,7 @@ pub(super) fn compose_metadata_override_file( 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)?); if !volumes.is_empty() { content.push_str("\n volumes:\n"); for volume in volumes { diff --git a/cmd/devcontainer/src/runtime/compose/override_mounts.rs b/cmd/devcontainer/src/runtime/compose/override_mounts.rs index 0273fb0cd..e2e015bae 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 { @@ -35,13 +36,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 +48,26 @@ 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) } fn compose_mount_definition(value: &Value) -> Option { 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/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..3b557dcc1 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,50 @@ 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 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, + "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, + _ => {} + } + } + + if !has_mount_type || !has_source || !has_target { + 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 +141,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=,source=,target=[,...]" + ) +} + #[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 +195,27 @@ 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_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_container_smoke/basic.rs b/cmd/devcontainer/tests/runtime_container_smoke/basic.rs index aa8d24b02..4212c1d18 100644 --- a/cmd/devcontainer/tests/runtime_container_smoke/basic.rs +++ b/cmd/devcontainer/tests/runtime_container_smoke/basic.rs @@ -241,10 +241,11 @@ fn up_rejects_invalid_cli_mount_before_engine_invocation() { "{stderr}" ); - let invocations = harness.read_invocations(); + let invocation_log = harness.log_dir.join("invocations.log"); assert!( - !invocations.contains("run "), - "unexpected engine run: {invocations}" + !invocation_log.exists(), + "unexpected engine invocation log: {}", + fs::read_to_string(&invocation_log).unwrap_or_default() ); } From ac03394f3fdabf38ec5525f7722a0b8cdb8cdcd3 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Wed, 15 Apr 2026 21:57:34 +0200 Subject: [PATCH 04/15] Refresh parity inventory for mount changes --- docs/upstream/parity-inventory.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/upstream/parity-inventory.json b/docs/upstream/parity-inventory.json index 570ed7da2..195be9349 100644 --- a/docs/upstream/parity-inventory.json +++ b/docs/upstream/parity-inventory.json @@ -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" ] }, { From 49d33dd7acf21bf258de8b541f60d52ef9a140ee Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Wed, 15 Apr 2026 22:56:51 +0200 Subject: [PATCH 05/15] Add compose parity regression tests --- .../runtime_container_smoke/compose_flow.rs | 135 +++++++++++++++++- .../compose_project.rs | 103 +++++++++++++ 2 files changed, 236 insertions(+), 2 deletions(-) diff --git a/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs b/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs index f4f4ebb15..cc24360cf 100644 --- a/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs +++ b/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs @@ -58,7 +58,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 +69,70 @@ fn up_starts_compose_services_and_exec_uses_compose_container_lookup() { assert!(exec_log.contains("/bin/sh -lc echo ready")); } +#[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 +316,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 +326,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..f80405e98 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,94 @@ 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( + &[ + "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(&format!( + " -f {} -f {} up", + workspace.join("docker-compose.yml").display(), + workspace.join("docker-compose.override.yml").display() + ))); +} + +#[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 ")); +} From 12a7ce39c7cb46d58852fd60629c709354eebe9a Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 16 Apr 2026 06:37:47 +0200 Subject: [PATCH 06/15] Align compose runtime defaults with upstream --- .../src/commands/common/config_resolution.rs | 21 +++++--- cmd/devcontainer/src/runtime/compose/mod.rs | 33 +++++++++--- .../src/runtime/compose/service.rs | 52 ++++++++++++++++++- .../src/runtime/container/discovery.rs | 4 +- .../src/runtime/context/workspace.rs | 8 +++ cmd/devcontainer/src/runtime/engine.rs | 40 +++++++++++++- .../compose_project.rs | 21 ++++++-- 7 files changed, 155 insertions(+), 24 deletions(-) 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/mod.rs b/cmd/devcontainer/src/runtime/compose/mod.rs index 74fe6fd94..15e8429b6 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 Result<(), String> { let spec = load_compose_spec(resolved)? .ok_or_else(|| "Compose configuration was expected but not found".to_string())?; @@ -150,14 +155,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/service.rs b/cmd/devcontainer/src/runtime/compose/service.rs index 682b0373a..7dbcdb73b 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; @@ -18,10 +20,11 @@ pub(super) struct ServiceDefinition { 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 +33,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, 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/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/tests/runtime_container_smoke/compose_project.rs b/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs index f80405e98..1cabffb68 100644 --- a/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs +++ b/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs @@ -163,11 +163,22 @@ fn up_uses_default_compose_files_when_docker_compose_file_array_is_empty() { assert!(output.status.success(), "{output:?}"); let invocations = harness.read_invocations(); - assert!(invocations.contains(&format!( - " -f {} -f {} up", - workspace.join("docker-compose.yml").display(), - workspace.join("docker-compose.override.yml").display() - ))); + 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] From bbd9226d43115c4413480bc215da09158daf214e Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 16 Apr 2026 06:47:54 +0200 Subject: [PATCH 07/15] Align compose override generation with upstream --- .../src/runtime/compose/override_file.rs | 143 ++++++++++++++++-- .../src/runtime/compose/override_mounts.rs | 44 ++++++ .../src/runtime/compose/override_yaml.rs | 16 +- .../src/runtime/compose/service.rs | 108 +++++++++++++ cmd/devcontainer/src/runtime/compose/tests.rs | 61 ++++++++ .../runtime_container_smoke/compose_flow.rs | 59 ++++++++ .../tests/support/runtime_harness.rs | 5 + .../support/runtime_harness/fake_engine.rs | 7 + 8 files changed, 431 insertions(+), 12 deletions(-) diff --git a/cmd/devcontainer/src/runtime/compose/override_file.rs b/cmd/devcontainer/src/runtime/compose/override_file.rs index 63b1d937b..33517eaa1 100644 --- a/cmd/devcontainer/src/runtime/compose/override_file.rs +++ b/cmd/devcontainer/src/runtime/compose/override_file.rs @@ -15,8 +15,15 @@ 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 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_metadata_override_file( resolved: &ResolvedConfig, @@ -24,6 +31,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 +57,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 +74,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)?); + 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) { @@ -146,12 +164,117 @@ pub(super) fn compose_metadata_override_file( " 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 e2e015bae..e74e173bc 100644 --- a/cmd/devcontainer/src/runtime/compose/override_mounts.rs +++ b/cmd/devcontainer/src/runtime/compose/override_mounts.rs @@ -13,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, } @@ -70,6 +75,45 @@ pub(super) fn compose_additional_volumes( 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 { match value { Value::String(text) => { 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 7dbcdb73b..3a3830596 100644 --- a/cmd/devcontainer/src/runtime/compose/service.rs +++ b/cmd/devcontainer/src/runtime/compose/service.rs @@ -15,6 +15,8 @@ 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( @@ -92,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 { @@ -119,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 { @@ -131,6 +145,8 @@ pub(super) fn inspect_service_definition( image, has_build, user, + entrypoint, + command, }) } @@ -138,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 2dd71a0b5..d7ea9d9db 100644 --- a/cmd/devcontainer/src/runtime/compose/tests.rs +++ b/cmd/devcontainer/src/runtime/compose/tests.rs @@ -361,6 +361,67 @@ 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_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"); diff --git a/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs b/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs index cc24360cf..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(); @@ -69,6 +93,41 @@ 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(); 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..9b3c45506 100644 --- a/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs +++ b/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs @@ -52,13 +52,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 *"- '"*"'"|*"- \""*"\""|*" - "*"="*) From 5f5a4713a560a9dcb78c89925742c1bd0499a81e Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 16 Apr 2026 06:52:16 +0200 Subject: [PATCH 08/15] Support cache-from and compose build flag parity --- cmd/devcontainer/src/runtime/compose/args.rs | 17 +- cmd/devcontainer/src/runtime/compose/mod.rs | 20 +- .../src/runtime/compose/override_file.rs | 24 +++ .../tests/runtime_build_smoke/compose.rs | 202 ++++++++++++++++++ .../support/runtime_harness/fake_engine.rs | 16 ++ 5 files changed, 262 insertions(+), 17 deletions(-) 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 15e8429b6..ffa53891d 100644 --- a/cmd/devcontainer/src/runtime/compose/mod.rs +++ b/cmd/devcontainer/src/runtime/compose/mod.rs @@ -77,6 +77,7 @@ pub(crate) fn build_service(resolved: &ResolvedConfig, args: &[String]) -> 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()); @@ -84,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)); } @@ -119,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() diff --git a/cmd/devcontainer/src/runtime/compose/override_file.rs b/cmd/devcontainer/src/runtime/compose/override_file.rs index 33517eaa1..cb54b62e3 100644 --- a/cmd/devcontainer/src/runtime/compose/override_file.rs +++ b/cmd/devcontainer/src/runtime/compose/override_file.rs @@ -16,6 +16,7 @@ use super::super::context::ResolvedConfig; use super::super::metadata::serialized_container_metadata; use super::super::paths::unique_temp_path; use super::service::{self, ServiceDefinition}; +use super::ComposeSpec; use override_mounts::{ compose_additional_volumes, compose_environment, compose_named_volumes, compose_workspace_volume, @@ -25,6 +26,29 @@ use override_yaml::{ 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, args: &[String], 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/support/runtime_harness/fake_engine.rs b/cmd/devcontainer/tests/support/runtime_harness/fake_engine.rs index 9b3c45506..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) From de0e488c97973477ccf73fe7ad9ea393c31aef32 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 16 Apr 2026 06:55:18 +0200 Subject: [PATCH 09/15] Document remaining compose parity gaps --- docs/upstream/compose-parity.md | 40 +++++++++++++++++++++++++++++ docs/upstream/parity-inventory.json | 8 +++--- 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 docs/upstream/compose-parity.md 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 195be9349..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" ] }, { @@ -576,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" ] }, { @@ -686,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" ] }, { @@ -703,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" ] }, From 17497bf4f0cdef8016c60392a3c0dbfa6d64b266 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 16 Apr 2026 07:32:25 +0200 Subject: [PATCH 10/15] Fix compose override and default file parity --- cmd/devcontainer/src/runtime/compose/mod.rs | 9 ++-- .../src/runtime/compose/override_file.rs | 20 +++------ .../src/runtime/compose/service.rs | 23 +++++----- cmd/devcontainer/src/runtime/compose/tests.rs | 32 ++++++++++++++ .../compose_project.rs | 43 +++++++++++++++++++ 5 files changed, 97 insertions(+), 30 deletions(-) diff --git a/cmd/devcontainer/src/runtime/compose/mod.rs b/cmd/devcontainer/src/runtime/compose/mod.rs index ffa53891d..e299e942b 100644 --- a/cmd/devcontainer/src/runtime/compose/mod.rs +++ b/cmd/devcontainer/src/runtime/compose/mod.rs @@ -41,11 +41,10 @@ pub(crate) fn load_compose_spec(resolved: &ResolvedConfig) -> Result Result, String> { match configuration.get("dockerComposeFile") { Some(Value::String(value)) => Ok(vec![resolve_relative(config_root, value)]), @@ -35,20 +35,20 @@ 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(Value::Array(_)) => default_compose_files(default_files_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> { +fn default_compose_files(default_files_root: &Path) -> Result, String> { if let Some(compose_files) = - compose_files_from_env(std::env::var_os("COMPOSE_FILE"), workspace_root) + compose_files_from_env(std::env::var_os("COMPOSE_FILE"), default_files_root) { return Ok(compose_files); } - let env_file = workspace_root.join(".env"); + let env_file = default_files_root.join(".env"); if let Ok(raw) = fs::read_to_string(&env_file) { if let Some(value) = raw.lines().find_map(|line| { line.trim() @@ -58,29 +58,32 @@ fn default_compose_files(workspace_root: &Path) -> Result, String> .map(str::to_string) }) { if let Some(compose_files) = - compose_files_from_env(Some(OsString::from(value)), workspace_root) + compose_files_from_env(Some(OsString::from(value)), default_files_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"); + let mut files = vec![default_files_root.join("docker-compose.yml")]; + let override_file = default_files_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> { +fn compose_files_from_env( + value: Option, + default_files_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) + default_files_root.join(path) } }) .collect::>(); diff --git a/cmd/devcontainer/src/runtime/compose/tests.rs b/cmd/devcontainer/src/runtime/compose/tests.rs index d7ea9d9db..9c259f0b0 100644 --- a/cmd/devcontainer/src/runtime/compose/tests.rs +++ b/cmd/devcontainer/src/runtime/compose/tests.rs @@ -390,6 +390,38 @@ fn metadata_override_file_wraps_entrypoints_with_a_keepalive_entrypoint() { 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"); diff --git a/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs b/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs index 1cabffb68..3ea5534e4 100644 --- a/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs +++ b/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs @@ -181,6 +181,49 @@ fn up_uses_default_compose_files_when_docker_compose_file_array_is_empty() { ); } +#[test] +fn up_resolves_compose_file_env_paths_relative_to_caller_cwd_when_array_is_empty() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let outside = harness.root.join("outside"); + let compose_dir = outside.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(&outside), + ); + + 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(); From 04f590ecdafe550d2a2d80f715d0e48c87bddfdd Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 16 Apr 2026 07:35:04 +0200 Subject: [PATCH 11/15] Fix compose reset project name resolution --- cmd/devcontainer/tests/cli_smoke.rs | 2 + .../tests/cli_smoke/devcontainer_context.rs | 82 +++++++++++++++++++ devcontainer-context.sh | 61 +++++++++++++- 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 cmd/devcontainer/tests/cli_smoke/devcontainer_context.rs diff --git a/cmd/devcontainer/tests/cli_smoke.rs b/cmd/devcontainer/tests/cli_smoke.rs index e9f978a0d..3be34704b 100644 --- a/cmd/devcontainer/tests/cli_smoke.rs +++ b/cmd/devcontainer/tests/cli_smoke.rs @@ -4,6 +4,8 @@ mod support; #[path = "cli_smoke/collections.rs"] mod collections; +#[path = "cli_smoke/devcontainer_context.rs"] +mod devcontainer_context; #[path = "cli_smoke/help.rs"] mod help; #[path = "cli_smoke/lockfile.rs"] diff --git a/cmd/devcontainer/tests/cli_smoke/devcontainer_context.rs b/cmd/devcontainer/tests/cli_smoke/devcontainer_context.rs new file mode 100644 index 000000000..c4644ad35 --- /dev/null +++ b/cmd/devcontainer/tests/cli_smoke/devcontainer_context.rs @@ -0,0 +1,82 @@ +//! Smoke tests for the local devcontainer context helper script. + +use std::fs; +use std::path::Path; +use std::process::Command; + +use crate::support::test_support::{repo_root, unique_temp_dir}; + +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 reset_uses_resolved_compose_project_name() { + let root = unique_temp_dir("devcontainer-cli-smoke"); + let workspace = root.join("workspace"); + let home = root.join("home"); + let bin = root.join("bin"); + let log_dir = root.join("logs"); + fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); + fs::create_dir_all(&home).expect("home dir"); + fs::create_dir_all(&bin).expect("bin dir"); + fs::create_dir_all(&log_dir).expect("log dir"); + fs::write( + workspace.join(".devcontainer").join("devcontainer.json"), + "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", + ) + .expect("config"); + fs::write( + workspace.join(".devcontainer").join("docker-compose.yml"), + "name: Custom-Project-Name\nservices:\n app:\n image: alpine:3.20\n", + ) + .expect("compose"); + + let devcontainer_log = log_dir.join("devcontainer.log"); + let podman_log = log_dir.join("podman.log"); + write_executable_script( + &bin.join("devcontainer"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"{}\"\nexit 0\n", + devcontainer_log.display() + ), + ); + write_executable_script( + &bin.join("podman"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"{}\"\nexit 0\n", + podman_log.display() + ), + ); + write_executable_script(&bin.join("podman-compose"), "#!/bin/sh\nexit 0\n"); + + let path = format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + let output = Command::new("bash") + .arg(repo_root().join("devcontainer-context.sh")) + .arg("--reset") + .current_dir(&workspace) + .env("HOME", &home) + .env("PATH", path) + .output() + .expect("script should run"); + + assert!(output.status.success(), "{output:?}"); + let podman_log = fs::read_to_string(&podman_log).expect("podman log"); + assert!( + podman_log.contains("--project-name custom-project-name down -v --remove-orphans"), + "{podman_log}" + ); + + let _ = fs::remove_dir_all(root); +} diff --git a/devcontainer-context.sh b/devcontainer-context.sh index 748568822..b1b3fd12e 100644 --- a/devcontainer-context.sh +++ b/devcontainer-context.sh @@ -57,6 +57,7 @@ CONTAINER_CONFIG_DIR="/root/.config" CONTAINER_LOCAL_DIR="/root/.local" CONTAINER_SHELL="/bin/bash" CONTAINER_WORKSPACE_FOLDER="" +COMPOSE_PROJECT_NAME_OVERRIDE="" LOCAL_VOLUME_NAME="" PODMAN_DETACH_KEYS="ctrl-]" @@ -169,6 +170,61 @@ validate_compose_files() { done } +trim_whitespace() { + sed 's/^[[:space:]]*//; s/[[:space:]]*$//' +} + +sanitize_project_name() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-' +} + +resolve_compose_project_name() { + local candidate="" + local env_file + local compose_file + local line + local index + local sanitized + + if [[ -n "${COMPOSE_PROJECT_NAME:-}" ]]; then + candidate="$COMPOSE_PROJECT_NAME" + else + env_file="$(dirname "${COMPOSE_FILES[0]}")/.env" + if [[ -f "$env_file" ]]; then + while IFS= read -r line; do + case "$line" in + COMPOSE_PROJECT_NAME=*) + candidate="$(printf '%s' "${line#COMPOSE_PROJECT_NAME=}" | trim_whitespace)" + break + ;; + esac + done < "$env_file" + fi + + if [[ -z "$candidate" ]]; then + for ((index=${#COMPOSE_FILES[@]} - 1; index >= 0; index--)); do + compose_file="${COMPOSE_FILES[index]}" + while IFS= read -r line; do + if [[ "$line" == [[:space:]]* ]]; then + continue + fi + case "$line" in + name:*) + candidate="$(printf '%s' "${line#name:}" | trim_whitespace)" + break 2 + ;; + esac + done < "$compose_file" + done + fi + fi + + sanitized="$(sanitize_project_name "$candidate")" + if [[ -n "$sanitized" ]]; then + COMPOSE_PROJECT_NAME_OVERRIDE="$sanitized" + fi +} + resolve_host_git_config() { if [[ -f "$HOME/.gitconfig" ]]; then HOST_GIT_CONFIG="$HOME/.gitconfig" @@ -308,13 +364,15 @@ exec_in_devcontainer() { compose_reset() { local compose_args=() local compose_file + local project_name echo "Tearing down dev container..." for compose_file in "${COMPOSE_FILES[@]}"; do compose_args+=( -f "$compose_file" ) done - podman compose "${compose_args[@]}" --project-name "$PROJECT_NAME" down -v --remove-orphans + project_name="${COMPOSE_PROJECT_NAME_OVERRIDE:-$PROJECT_NAME}" + podman compose "${compose_args[@]}" --project-name "$project_name" down -v --remove-orphans } start_devcontainer() { @@ -379,6 +437,7 @@ if [[ "$DEVCONTAINER_KIND" == "compose" ]]; then # Export UID for compose file interpolation (bash special variable, not exported by default) export UID validate_compose_files + resolve_compose_project_name require_command "podman-compose" "podman-compose is required for compose-based dev containers" COMPOSE_PATH="$(command -v podman-compose)" DEVCONTAINER_RUNTIME_ARGS+=(--docker-compose-path "$COMPOSE_PATH") From 325536674a54418fe8d89d8a7886a9ed4ed02654 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 16 Apr 2026 07:38:30 +0200 Subject: [PATCH 12/15] Align empty compose discovery with workspace root --- cmd/devcontainer/src/runtime/compose/mod.rs | 9 ++++---- .../src/runtime/compose/override_file.rs | 10 ++++---- .../src/runtime/compose/service.rs | 23 ++++++++----------- .../compose_project.rs | 10 ++++---- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/cmd/devcontainer/src/runtime/compose/mod.rs b/cmd/devcontainer/src/runtime/compose/mod.rs index e299e942b..ffa53891d 100644 --- a/cmd/devcontainer/src/runtime/compose/mod.rs +++ b/cmd/devcontainer/src/runtime/compose/mod.rs @@ -41,10 +41,11 @@ pub(crate) fn load_compose_spec(resolved: &ResolvedConfig) -> Result Result, String> { match configuration.get("dockerComposeFile") { Some(Value::String(value)) => Ok(vec![resolve_relative(config_root, value)]), @@ -35,20 +35,20 @@ pub(super) fn compose_files( .ok_or_else(|| "dockerComposeFile entries must be strings".to_string()) }) .collect(), - Some(Value::Array(_)) => default_compose_files(default_files_root), + 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(default_files_root: &Path) -> Result, 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"), default_files_root) + compose_files_from_env(std::env::var_os("COMPOSE_FILE"), workspace_root) { return Ok(compose_files); } - let env_file = default_files_root.join(".env"); + 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() @@ -58,32 +58,29 @@ fn default_compose_files(default_files_root: &Path) -> Result, Stri .map(str::to_string) }) { if let Some(compose_files) = - compose_files_from_env(Some(OsString::from(value)), default_files_root) + compose_files_from_env(Some(OsString::from(value)), workspace_root) { return Ok(compose_files); } } } - let mut files = vec![default_files_root.join("docker-compose.yml")]; - let override_file = default_files_root.join("docker-compose.override.yml"); + 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, - default_files_root: &Path, -) -> Option> { +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 { - default_files_root.join(path) + workspace_root.join(path) } }) .collect::>(); diff --git a/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs b/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs index 3ea5534e4..710f4df87 100644 --- a/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs +++ b/cmd/devcontainer/tests/runtime_container_smoke/compose_project.rs @@ -150,7 +150,7 @@ fn up_uses_default_compose_files_when_docker_compose_file_array_is_empty() { ); let fake_podman = harness.fake_podman.to_string_lossy().to_string(); - let output = harness.run( + let output = harness.run_in_dir( &[ "up", "--docker-path", @@ -159,6 +159,7 @@ fn up_uses_default_compose_files_when_docker_compose_file_array_is_empty() { workspace.to_string_lossy().as_ref(), ], &[], + Some(&workspace), ); assert!(output.status.success(), "{output:?}"); @@ -182,11 +183,10 @@ fn up_uses_default_compose_files_when_docker_compose_file_array_is_empty() { } #[test] -fn up_resolves_compose_file_env_paths_relative_to_caller_cwd_when_array_is_empty() { +fn up_resolves_compose_file_env_paths_relative_to_workspace_when_array_is_empty() { let harness = RuntimeHarness::new(); let workspace = harness.workspace(); - let outside = harness.root.join("outside"); - let compose_dir = outside.join("relative-compose"); + 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( @@ -209,7 +209,7 @@ fn up_resolves_compose_file_env_paths_relative_to_caller_cwd_when_array_is_empty workspace.to_string_lossy().as_ref(), ], &[("COMPOSE_FILE", "relative-compose/docker-compose.yml")], - Some(&outside), + Some(&workspace), ); assert!(output.status.success(), "{output:?}"); From 810f9dd79ee2898eeffea88806ce550039b5e0e6 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 16 Apr 2026 19:16:59 +0200 Subject: [PATCH 13/15] Remove tracked devcontainer context helper --- cmd/devcontainer/tests/cli_smoke.rs | 2 - .../tests/cli_smoke/devcontainer_context.rs | 82 --- devcontainer-context.sh | 476 ------------------ 3 files changed, 560 deletions(-) delete mode 100644 cmd/devcontainer/tests/cli_smoke/devcontainer_context.rs delete mode 100644 devcontainer-context.sh diff --git a/cmd/devcontainer/tests/cli_smoke.rs b/cmd/devcontainer/tests/cli_smoke.rs index 3be34704b..e9f978a0d 100644 --- a/cmd/devcontainer/tests/cli_smoke.rs +++ b/cmd/devcontainer/tests/cli_smoke.rs @@ -4,8 +4,6 @@ mod support; #[path = "cli_smoke/collections.rs"] mod collections; -#[path = "cli_smoke/devcontainer_context.rs"] -mod devcontainer_context; #[path = "cli_smoke/help.rs"] mod help; #[path = "cli_smoke/lockfile.rs"] diff --git a/cmd/devcontainer/tests/cli_smoke/devcontainer_context.rs b/cmd/devcontainer/tests/cli_smoke/devcontainer_context.rs deleted file mode 100644 index c4644ad35..000000000 --- a/cmd/devcontainer/tests/cli_smoke/devcontainer_context.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! Smoke tests for the local devcontainer context helper script. - -use std::fs; -use std::path::Path; -use std::process::Command; - -use crate::support::test_support::{repo_root, unique_temp_dir}; - -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 reset_uses_resolved_compose_project_name() { - let root = unique_temp_dir("devcontainer-cli-smoke"); - let workspace = root.join("workspace"); - let home = root.join("home"); - let bin = root.join("bin"); - let log_dir = root.join("logs"); - fs::create_dir_all(workspace.join(".devcontainer")).expect("workspace config dir"); - fs::create_dir_all(&home).expect("home dir"); - fs::create_dir_all(&bin).expect("bin dir"); - fs::create_dir_all(&log_dir).expect("log dir"); - fs::write( - workspace.join(".devcontainer").join("devcontainer.json"), - "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", - ) - .expect("config"); - fs::write( - workspace.join(".devcontainer").join("docker-compose.yml"), - "name: Custom-Project-Name\nservices:\n app:\n image: alpine:3.20\n", - ) - .expect("compose"); - - let devcontainer_log = log_dir.join("devcontainer.log"); - let podman_log = log_dir.join("podman.log"); - write_executable_script( - &bin.join("devcontainer"), - &format!( - "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"{}\"\nexit 0\n", - devcontainer_log.display() - ), - ); - write_executable_script( - &bin.join("podman"), - &format!( - "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"{}\"\nexit 0\n", - podman_log.display() - ), - ); - write_executable_script(&bin.join("podman-compose"), "#!/bin/sh\nexit 0\n"); - - let path = format!( - "{}:{}", - bin.display(), - std::env::var("PATH").unwrap_or_default() - ); - let output = Command::new("bash") - .arg(repo_root().join("devcontainer-context.sh")) - .arg("--reset") - .current_dir(&workspace) - .env("HOME", &home) - .env("PATH", path) - .output() - .expect("script should run"); - - assert!(output.status.success(), "{output:?}"); - let podman_log = fs::read_to_string(&podman_log).expect("podman log"); - assert!( - podman_log.contains("--project-name custom-project-name down -v --remove-orphans"), - "{podman_log}" - ); - - let _ = fs::remove_dir_all(root); -} diff --git a/devcontainer-context.sh b/devcontainer-context.sh deleted file mode 100644 index b1b3fd12e..000000000 --- a/devcontainer-context.sh +++ /dev/null @@ -1,476 +0,0 @@ -#!/bin/bash -set -e - -# Wrapper script to run commands inside the dev container - -fail() { - echo "Error: $*" >&2 - exit 1 -} - -require_command() { - local command_name="$1" - local install_hint="$2" - - if ! command -v "$command_name" >/dev/null 2>&1; then - fail "$install_hint" - fi -} - -run_devcontainer() { - local exit_code=0 - - if devcontainer "$@"; then - return 0 - else - exit_code=$? - fi - - fail "devcontainer $1 failed with exit code $exit_code" -} - -usage() { - cat <; - close $handle; - my $parser = JSON::PP->new->relaxed; - my $parsed = $parser->decode($content); - print JSON::PP->new->canonical->encode($parsed); - ' "$DEVCONTAINER_CONFIG" -} - -resolve_compose_file_path() { - local config_dir="$1" - local compose_path="$2" - - case "$compose_path" in - /*) - printf '%s\n' "$compose_path" - ;; - *) - printf '%s/%s\n' "$config_dir" "$compose_path" - ;; - esac -} - -detect_devcontainer_type() { - local config_dir - local compose_file - local compose_kind - local normalized_config - local workspace_folder - - config_dir="$(dirname "$DEVCONTAINER_CONFIG")" - - normalized_config="$(normalize_devcontainer_config)" || fail "Unable to parse $DEVCONTAINER_CONFIG" - - compose_kind="$(jq -r ' - if has("dockerComposeFile") then - .dockerComposeFile | type - else - "null" - end - ' <<< "$normalized_config")" - - case "$compose_kind" in - null) - DEVCONTAINER_KIND="regular" - ;; - string) - DEVCONTAINER_KIND="compose" - COMPOSE_FILES=("$(jq -r '.dockerComposeFile' <<< "$normalized_config")") - ;; - array) - DEVCONTAINER_KIND="compose" - if ! jq -e '.dockerComposeFile | all(.[]?; type == "string")' >/dev/null <<< "$normalized_config"; then - fail "dockerComposeFile must be a string or an array of strings" - fi - mapfile -t COMPOSE_FILES < <(jq -r '.dockerComposeFile[]' <<< "$normalized_config") - ;; - *) - fail "dockerComposeFile must be a string or an array of strings" - ;; - esac - - workspace_folder="$(jq -r '.workspaceFolder // empty' <<< "$normalized_config")" - if [[ -z "$workspace_folder" ]]; then - fail "workspaceFolder must be set in $DEVCONTAINER_CONFIG" - fi - CONTAINER_WORKSPACE_FOLDER="$workspace_folder" - - if [[ "$DEVCONTAINER_KIND" == "compose" ]]; then - if [[ ${#COMPOSE_FILES[@]} -eq 0 ]]; then - fail "No compose files were found in $DEVCONTAINER_CONFIG" - fi - - for compose_file in "${!COMPOSE_FILES[@]}"; do - COMPOSE_FILES[compose_file]="$(resolve_compose_file_path "$config_dir" "${COMPOSE_FILES[compose_file]}")" - done - fi -} - -validate_compose_files() { - local compose_file - - for compose_file in "${COMPOSE_FILES[@]}"; do - if [[ ! -f "$compose_file" ]]; then - fail "Compose file '$compose_file' configured in $DEVCONTAINER_CONFIG was not found" - fi - done -} - -trim_whitespace() { - sed 's/^[[:space:]]*//; s/[[:space:]]*$//' -} - -sanitize_project_name() { - printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-' -} - -resolve_compose_project_name() { - local candidate="" - local env_file - local compose_file - local line - local index - local sanitized - - if [[ -n "${COMPOSE_PROJECT_NAME:-}" ]]; then - candidate="$COMPOSE_PROJECT_NAME" - else - env_file="$(dirname "${COMPOSE_FILES[0]}")/.env" - if [[ -f "$env_file" ]]; then - while IFS= read -r line; do - case "$line" in - COMPOSE_PROJECT_NAME=*) - candidate="$(printf '%s' "${line#COMPOSE_PROJECT_NAME=}" | trim_whitespace)" - break - ;; - esac - done < "$env_file" - fi - - if [[ -z "$candidate" ]]; then - for ((index=${#COMPOSE_FILES[@]} - 1; index >= 0; index--)); do - compose_file="${COMPOSE_FILES[index]}" - while IFS= read -r line; do - if [[ "$line" == [[:space:]]* ]]; then - continue - fi - case "$line" in - name:*) - candidate="$(printf '%s' "${line#name:}" | trim_whitespace)" - break 2 - ;; - esac - done < "$compose_file" - done - fi - fi - - sanitized="$(sanitize_project_name "$candidate")" - if [[ -n "$sanitized" ]]; then - COMPOSE_PROJECT_NAME_OVERRIDE="$sanitized" - fi -} - -resolve_host_git_config() { - if [[ -f "$HOME/.gitconfig" ]]; then - HOST_GIT_CONFIG="$HOME/.gitconfig" - fi -} - -ensure_host_config_dir() { - mkdir -p "$HOST_CONFIG_DIR" -} - -resolve_local_volume_name() { - LOCAL_VOLUME_NAME="${PROJECT_NAME}_root_local" -} - -ensure_local_volume() { - if [[ -z "$LOCAL_VOLUME_NAME" ]]; then - fail "Local volume name was not resolved" - fi - - podman volume create \ - --ignore \ - --label "devcontainer.local_folder=$WORKSPACE_FOLDER" \ - --label "devcontainer.config_file=$DEVCONTAINER_CONFIG" \ - --label "devcontainer.volume_role=root_local" \ - "$LOCAL_VOLUME_NAME" >/dev/null -} - -is_container_running() { - podman ps \ - --quiet \ - --filter "label=devcontainer.local_folder=$WORKSPACE_FOLDER" \ - --filter "label=devcontainer.config_file=$DEVCONTAINER_CONFIG" -} - -get_existing_container_id() { - podman ps \ - --all \ - --quiet \ - --filter "label=devcontainer.local_folder=$WORKSPACE_FOLDER" \ - --filter "label=devcontainer.config_file=$DEVCONTAINER_CONFIG" -} - -container_has_git_config_mount() { - local container_id="$1" - - if [[ -z "$HOST_GIT_CONFIG" || -z "$container_id" ]]; then - return 1 - fi - - podman inspect "$container_id" \ - --format '{{range .Mounts}}{{println .Destination}}{{end}}' | grep -Fxq "$CONTAINER_GIT_CONFIG" -} - -container_has_host_config_mount() { - local container_id="$1" - - if [[ -z "$container_id" ]]; then - return 1 - fi - - podman inspect "$container_id" \ - --format '{{range .Mounts}}{{println .Type .Destination}}{{end}}' | grep -Fxq "bind $CONTAINER_CONFIG_DIR" -} - -container_has_root_local_volume_mount() { - local container_id="$1" - - if [[ -z "$container_id" ]]; then - return 1 - fi - - podman inspect "$container_id" \ - --format '{{range .Mounts}}{{println .Type .Destination}}{{end}}' | grep -Fxq "volume $CONTAINER_LOCAL_DIR" -} - -warn_if_git_config_mount_missing() { - local container_id - - if [[ -z "$HOST_GIT_CONFIG" ]]; then - return - fi - - container_id="$(get_existing_container_id | head -n 1)" - if [[ -n "$container_id" ]] && ! container_has_git_config_mount "$container_id"; then - echo "Notice: existing dev container was created without the host git config mount. Re-run with --reset to recreate it with git config sharing enabled." >&2 - fi -} - -warn_if_host_config_mount_missing() { - local container_id - - container_id="$(get_existing_container_id | head -n 1)" - if [[ -n "$container_id" ]] && ! container_has_host_config_mount "$container_id"; then - echo "Notice: existing dev container was created without the host ~/.config bind mount. Re-run with --reset to recreate it with shared config enabled." >&2 - fi -} - -warn_if_root_local_volume_mount_missing() { - local container_id - - container_id="$(get_existing_container_id | head -n 1)" - if [[ -n "$container_id" ]] && ! container_has_root_local_volume_mount "$container_id"; then - echo "Notice: existing dev container was created without the persistent /root/.local volume. Re-run with --reset to recreate it with local state persistence enabled." >&2 - fi -} - -get_running_container_id() { - is_container_running | head -n 1 -} - -exec_in_devcontainer() { - local container_id - local exec_args=() - - container_id="$(get_running_container_id)" - if [[ -z "$container_id" ]]; then - fail "No running dev container found for $WORKSPACE_FOLDER" - fi - - exec_args=(exec) - if [[ -t 0 && -t 1 ]]; then - exec_args+=(--interactive --tty --detach-keys "$PODMAN_DETACH_KEYS") - elif [[ -t 0 ]]; then - exec_args+=(--interactive) - fi - - exec_args+=(--workdir "$CONTAINER_WORKSPACE_FOLDER") - exec_args+=(-e "SHELL=$CONTAINER_SHELL") - - if [[ -n "$HOST_GIT_CONFIG" ]]; then - exec_args+=(-e "GIT_CONFIG_GLOBAL=$CONTAINER_GIT_CONFIG") - fi - - exec podman "${exec_args[@]}" "$container_id" "$@" -} - -compose_reset() { - local compose_args=() - local compose_file - local project_name - - echo "Tearing down dev container..." - for compose_file in "${COMPOSE_FILES[@]}"; do - compose_args+=( -f "$compose_file" ) - done - - project_name="${COMPOSE_PROJECT_NAME_OVERRIDE:-$PROJECT_NAME}" - podman compose "${compose_args[@]}" --project-name "$project_name" down -v --remove-orphans -} - -start_devcontainer() { - local up_args=(up) - - up_args+=("${DEVCONTAINER_UP_ARGS[@]}") - - if [[ "$1" == "reset" && "$DEVCONTAINER_KIND" != "compose" ]]; then - up_args+=(--remove-existing-container) - fi - - echo "Starting dev container..." - run_devcontainer "${up_args[@]}" - run_devcontainer run-user-commands "${DEVCONTAINER_RUNTIME_ARGS[@]}" -} - -# Parse flags -while [[ $# -gt 0 ]]; do - case "$1" in - --help) - usage - exit 0 - ;; - --reset) - RESET=true - shift - ;; - -*) - echo "Error: Unknown option '$1'" >&2 - echo "Use --help for usage information" >&2 - exit 1 - ;; - *) - break - ;; - esac -done - -WORKSPACE_FOLDER="$(pwd -P)" -WORKSPACE_NAME="$(basename "$WORKSPACE_FOLDER")" -# Project name used by devcontainer CLI: _devcontainer -PROJECT_NAME="${WORKSPACE_NAME}_devcontainer" - -# Check dependencies -require_command "devcontainer" "The Dev Container CLI ('devcontainer') is not installed or not in PATH. Install it from https://github.com/devcontainers/cli or with 'npm install -g @devcontainers/cli'." -require_command "jq" "jq is required to inspect the dev container configuration" -require_command "perl" "perl is required to parse JSONC dev container configurations" -require_command "podman" "podman is not installed or not in PATH" - -resolve_devcontainer_config -detect_devcontainer_type -resolve_host_git_config -ensure_host_config_dir -resolve_local_volume_name -ensure_local_volume - -DOCKER_PATH="$(command -v podman)" -DEVCONTAINER_RUNTIME_ARGS=(--docker-path "$DOCKER_PATH" --workspace-folder "$WORKSPACE_FOLDER" --config "$DEVCONTAINER_CONFIG") -DEVCONTAINER_RUNTIME_ARGS+=(--remote-env "SHELL=$CONTAINER_SHELL") - -if [[ "$DEVCONTAINER_KIND" == "compose" ]]; then - # Export UID for compose file interpolation (bash special variable, not exported by default) - export UID - validate_compose_files - resolve_compose_project_name - require_command "podman-compose" "podman-compose is required for compose-based dev containers" - COMPOSE_PATH="$(command -v podman-compose)" - DEVCONTAINER_RUNTIME_ARGS+=(--docker-compose-path "$COMPOSE_PATH") -fi - -if [[ -n "$HOST_GIT_CONFIG" ]]; then - DEVCONTAINER_RUNTIME_ARGS+=(--remote-env "GIT_CONFIG_GLOBAL=$CONTAINER_GIT_CONFIG") -fi - -DEVCONTAINER_UP_ARGS=("${DEVCONTAINER_RUNTIME_ARGS[@]}") -DEVCONTAINER_UP_ARGS+=(--mount "type=bind,source=$HOST_CONFIG_DIR,target=$CONTAINER_CONFIG_DIR") -DEVCONTAINER_UP_ARGS+=(--mount "type=volume,source=$LOCAL_VOLUME_NAME,target=$CONTAINER_LOCAL_DIR") - -if [[ -n "$HOST_GIT_CONFIG" ]]; then - DEVCONTAINER_UP_ARGS+=(--mount "type=bind,source=$HOST_GIT_CONFIG,target=$CONTAINER_GIT_CONFIG") -fi - -if ! $RESET; then - warn_if_host_config_mount_missing - warn_if_root_local_volume_mount_missing - warn_if_git_config_mount_missing -fi - -if $RESET; then - if [[ "$DEVCONTAINER_KIND" == "compose" ]]; then - compose_reset - fi - start_devcontainer reset -elif [[ -z "$(is_container_running)" ]]; then - start_devcontainer start -fi - -# If command provided, exec it -if [[ $# -gt 0 ]]; then - exec_in_devcontainer "$@" -fi From a620dc460ca2a200f2c8902ae65ef378ce161d72 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 16 Apr 2026 19:23:00 +0200 Subject: [PATCH 14/15] Fix clippy lint in cli help resolution --- cmd/devcontainer/src/cli.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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; }; From 0b28e4975b586b8e3824d28571920903183601a6 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Thu, 16 Apr 2026 19:51:49 +0200 Subject: [PATCH 15/15] Fix mount regression handling --- .../src/runtime/compose/override_mounts.rs | 22 +++++++++++- cmd/devcontainer/src/runtime/compose/tests.rs | 34 +++++++++++++++++++ cmd/devcontainer/src/runtime/mounts.rs | 17 ++++++++-- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/cmd/devcontainer/src/runtime/compose/override_mounts.rs b/cmd/devcontainer/src/runtime/compose/override_mounts.rs index e74e173bc..90c722342 100644 --- a/cmd/devcontainer/src/runtime/compose/override_mounts.rs +++ b/cmd/devcontainer/src/runtime/compose/override_mounts.rs @@ -175,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 })) } @@ -323,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/tests.rs b/cmd/devcontainer/src/runtime/compose/tests.rs index 9c259f0b0..b51f34914 100644 --- a/cmd/devcontainer/src/runtime/compose/tests.rs +++ b/cmd/devcontainer/src/runtime/compose/tests.rs @@ -523,6 +523,40 @@ 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); diff --git a/cmd/devcontainer/src/runtime/mounts.rs b/cmd/devcontainer/src/runtime/mounts.rs index 3b557dcc1..fdacdeb44 100644 --- a/cmd/devcontainer/src/runtime/mounts.rs +++ b/cmd/devcontainer/src/runtime/mounts.rs @@ -103,6 +103,7 @@ pub(crate) fn validate_cli_mount_values(mounts: &[String]) -> Result<(), String> } 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; @@ -117,7 +118,10 @@ pub(crate) fn validate_cli_mount_value(mount: &str) -> Result<(), String> { }; let value = value.trim_matches('"'); match key { - "type" if matches!(value, "bind" | "volume") => has_mount_type = true, + "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, @@ -125,7 +129,9 @@ pub(crate) fn validate_cli_mount_value(mount: &str) -> Result<(), String> { } } - if !has_mount_type || !has_source || !has_target { + let requires_source = !is_volume_mount; + + if !has_mount_type || !has_target || (requires_source && !has_source) { return Err(invalid_cli_mount_error(mount)); } @@ -143,7 +149,7 @@ fn mount_option_value(value: &Value) -> Option { fn invalid_cli_mount_error(mount: &str) -> String { format!( - "Invalid value for option --mount: {mount}. Expected type=,source=,target=[,...]" + "Invalid value for option --mount: {mount}. Expected type=,target=[,...], with source= required for bind mounts" ) } @@ -204,6 +210,11 @@ mod tests { .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 =