diff --git a/README.md b/README.md index 7561679a9..c7c619072 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,24 @@ export DEVCONTAINER_DOCKER_COMPOSE_PATH=podman-compose devcontainer up --workspace-folder . ``` -Path values are executable paths, not shell command strings with arguments. Use +To use the same non-standard config path across commands: + +```bash +export DEVCONTAINER_CONFIG=.devcontainer/podman/devcontainer.json +devcontainer up --workspace-folder . +devcontainer exec uv sync +``` + +`DEVCONTAINER_CONFIG` accepts the same absolute or relative paths as `--config`. +Relative paths resolve against `--workspace-folder` when it is provided, or the +current directory otherwise. Container engine path values are executable paths, +not shell command strings with arguments. Use `DEVCONTAINER_DOCKER_COMPOSE_PATH=podman-compose`, not `DEVCONTAINER_DOCKER_COMPOSE_PATH="podman compose"`. | CLI flag | Environment variable | | --- | --- | +| `--config` | `DEVCONTAINER_CONFIG` | | `--docker-path` | `DEVCONTAINER_DOCKER_PATH` | | `--docker-compose-path` | `DEVCONTAINER_DOCKER_COMPOSE_PATH` | | `--buildkit` | `DEVCONTAINER_BUILDKIT` | diff --git a/cmd/devcontainer/src/commands/common.rs b/cmd/devcontainer/src/commands/common.rs index c4d2202bf..8567aa030 100644 --- a/cmd/devcontainer/src/commands/common.rs +++ b/cmd/devcontainer/src/commands/common.rs @@ -9,18 +9,18 @@ mod manifest; #[cfg(not(target_os = "linux"))] pub(crate) use args::DEVCONTAINER_WORKSPACE_MOUNT_CONSISTENCY; pub(crate) use args::{ - env_default_bool_option, env_default_option_value, has_flag, parse_json_string_array_option, - parse_option_value, parse_option_values, remote_env_overrides, runtime_options, - runtime_process_request, secrets_env, validate_choice_option, validate_number_option, - validate_option_values, validate_paired_options, validate_runtime_env_defaults, - DEVCONTAINER_DOCKER_COMPOSE_PATH, DEVCONTAINER_DOCKER_PATH, + config_option_value, env_default_bool_option, env_default_option_value, has_flag, + parse_json_string_array_option, parse_option_value, parse_option_values, remote_env_overrides, + runtime_options, runtime_process_request, secrets_env, validate_choice_option, + validate_number_option, validate_option_values, validate_paired_options, + validate_runtime_env_defaults, DEVCONTAINER_DOCKER_COMPOSE_PATH, DEVCONTAINER_DOCKER_PATH, DEVCONTAINER_MOUNT_GIT_WORKTREE_COMMON_DIR, DEVCONTAINER_MOUNT_WORKSPACE_GIT_ROOT, }; #[cfg(test)] pub(crate) use args::{ - test_env_defaults, DEVCONTAINER_BUILDKIT, DEVCONTAINER_CONTAINER_DATA_FOLDER, - DEVCONTAINER_GPU_AVAILABILITY, DEVCONTAINER_UPDATE_REMOTE_USER_UID_DEFAULT, - DEVCONTAINER_USER_DATA_FOLDER, + test_env_defaults, DEVCONTAINER_BUILDKIT, DEVCONTAINER_CONFIG, + DEVCONTAINER_CONTAINER_DATA_FOLDER, DEVCONTAINER_GPU_AVAILABILITY, + DEVCONTAINER_UPDATE_REMOTE_USER_UID_DEFAULT, DEVCONTAINER_USER_DATA_FOLDER, }; pub(crate) use config_resolution::{ load_resolved_config, load_resolved_config_with_id_labels, resolve_override_config_path, diff --git a/cmd/devcontainer/src/commands/common/args.rs b/cmd/devcontainer/src/commands/common/args.rs index 88eaa031d..ec1d7a7ef 100644 --- a/cmd/devcontainer/src/commands/common/args.rs +++ b/cmd/devcontainer/src/commands/common/args.rs @@ -15,6 +15,7 @@ use crate::process_runner::{ProcessLogLevel, ProcessRequest}; pub(crate) const DEVCONTAINER_DOCKER_PATH: &str = "DEVCONTAINER_DOCKER_PATH"; pub(crate) const DEVCONTAINER_DOCKER_COMPOSE_PATH: &str = "DEVCONTAINER_DOCKER_COMPOSE_PATH"; +pub(crate) const DEVCONTAINER_CONFIG: &str = "DEVCONTAINER_CONFIG"; pub(crate) const DEVCONTAINER_BUILDKIT: &str = "DEVCONTAINER_BUILDKIT"; pub(crate) const DEVCONTAINER_USER_DATA_FOLDER: &str = "DEVCONTAINER_USER_DATA_FOLDER"; pub(crate) const DEVCONTAINER_CONTAINER_DATA_FOLDER: &str = "DEVCONTAINER_CONTAINER_DATA_FOLDER"; @@ -95,6 +96,10 @@ pub(crate) fn env_default_option_value( parse_option_value(args, option).or_else(|| nonblank_env_value(env_var)) } +pub(crate) fn config_option_value(args: &[String]) -> Option { + env_default_option_value(args, "--config", DEVCONTAINER_CONFIG) +} + pub(crate) fn env_default_choice_value( args: &[String], option: &str, diff --git a/cmd/devcontainer/src/commands/common/config_resolution.rs b/cmd/devcontainer/src/commands/common/config_resolution.rs index b76e98e9c..0d74954dd 100644 --- a/cmd/devcontainer/src/commands/common/config_resolution.rs +++ b/cmd/devcontainer/src/commands/common/config_resolution.rs @@ -22,7 +22,7 @@ pub(crate) fn resolve_read_configuration_path( )?; let explicit_workspace = parse_option_value(args, "--workspace-folder").map(PathBuf::from); - let explicit_config = parse_option_value(args, "--config").map(PathBuf::from); + let explicit_config = super::config_option_value(args).map(PathBuf::from); let override_config = resolve_override_config_path(args)?; let initial_workspace = match explicit_workspace.clone() { @@ -31,6 +31,13 @@ pub(crate) fn resolve_read_configuration_path( env::current_dir().map_err(|_| "Unable to determine workspace folder".to_string())? } }; + let explicit_config = explicit_config.map(|config_file| { + if explicit_workspace.is_none() && config_file.is_relative() { + initial_workspace.join(config_file) + } else { + config_file + } + }); let workspace_folder = if explicit_workspace.is_some() { initial_workspace.clone() @@ -193,7 +200,9 @@ mod tests { use std::collections::HashMap; use std::fs; - use crate::commands::common::DEVCONTAINER_LOCAL_FOLDER_LABEL; + use crate::commands::common::{ + test_env_defaults, DEVCONTAINER_CONFIG, DEVCONTAINER_LOCAL_FOLDER_LABEL, + }; use crate::test_support::unique_temp_dir; use super::{ @@ -201,6 +210,74 @@ mod tests { resolve_read_configuration_path, resolved_workspace_path, }; + #[test] + fn environment_config_selects_relative_path_and_cli_takes_precedence() { + let workspace = unique_temp_dir("devcontainer-config-resolution-env"); + let default_config = workspace.join(".devcontainer").join("devcontainer.json"); + let env_config = workspace + .join(".devcontainer") + .join("podman") + .join("devcontainer.json"); + fs::create_dir_all(default_config.parent().expect("default config parent")) + .expect("default config dir"); + fs::create_dir_all(env_config.parent().expect("env config parent")) + .expect("env config dir"); + fs::write(&default_config, r#"{"image":"default"}"#).expect("default config"); + fs::write(&env_config, r#"{"image":"env"}"#).expect("env config"); + + let env = test_env_defaults(&[( + DEVCONTAINER_CONFIG, + ".devcontainer/podman/devcontainer.json", + )]); + let workspace_args = vec![ + "--workspace-folder".to_string(), + workspace.display().to_string(), + ]; + let (_, selected_config, selected) = + load_resolved_config(&workspace_args).expect("environment config"); + assert_eq!( + selected_config, + fs::canonicalize(&env_config).expect("canonical env config") + ); + assert_eq!(selected["image"], "env"); + + let cli_args = vec![ + "--workspace-folder".to_string(), + workspace.display().to_string(), + "--config".to_string(), + ".devcontainer/devcontainer.json".to_string(), + ]; + let (_, selected_config, selected) = load_resolved_config(&cli_args).expect("CLI config"); + assert_eq!( + selected_config, + fs::canonicalize(&default_config).expect("canonical default config") + ); + assert_eq!(selected["image"], "default"); + + drop(env); + let absolute_config = + test_env_defaults(&[(DEVCONTAINER_CONFIG, env_config.to_string_lossy().as_ref())]); + let (_, selected_config, selected) = + load_resolved_config(&workspace_args).expect("absolute environment config"); + assert_eq!( + selected_config, + fs::canonicalize(&env_config).expect("canonical env config") + ); + assert_eq!(selected["image"], "env"); + + drop(absolute_config); + let _blank_env = test_env_defaults(&[(DEVCONTAINER_CONFIG, " ")]); + let (_, selected_config, selected) = + load_resolved_config(&workspace_args).expect("default config"); + assert_eq!( + selected_config, + fs::canonicalize(&default_config).expect("canonical default config") + ); + assert_eq!(selected["image"], "default"); + + let _ = fs::remove_dir_all(workspace); + } + #[test] fn load_resolved_config_with_id_labels_recomputes_devcontainer_id_from_override_labels() { let workspace = unique_temp_dir("devcontainer-config-resolution"); diff --git a/cmd/devcontainer/src/commands/configuration/load.rs b/cmd/devcontainer/src/commands/configuration/load.rs index e6147fde1..856c402da 100644 --- a/cmd/devcontainer/src/commands/configuration/load.rs +++ b/cmd/devcontainer/src/commands/configuration/load.rs @@ -28,7 +28,7 @@ pub(super) fn load_optional_config(args: &[String]) -> Result bool { common::parse_option_value(args, "--container-id").is_some() - && common::parse_option_value(args, "--config").is_none() + && common::config_option_value(args).is_none() && common::parse_option_value(args, "--workspace-folder").is_none() && error.starts_with("Unable to locate a dev container config at ") } @@ -36,6 +36,7 @@ fn missing_config_is_optional_for_container_inspection(args: &[String], error: & #[cfg(test)] mod tests { use super::missing_config_is_optional_for_container_inspection; + use crate::commands::common::{test_env_defaults, DEVCONTAINER_CONFIG}; #[test] fn missing_config_is_optional_only_for_container_inspection_without_explicit_sources() { @@ -70,6 +71,15 @@ mod tests { ), "explicit config must not ignore missing config" ); + let env = test_env_defaults(&[(DEVCONTAINER_CONFIG, "missing-devcontainer.json")]); + assert!( + !missing_config_is_optional_for_container_inspection( + &args(&["--container-id", "container-123"]), + missing_error + ), + "environment config must not be ignored" + ); + drop(env); assert!( !missing_config_is_optional_for_container_inspection(&args(&[]), missing_error), "missing config without container inspection must remain an error" diff --git a/cmd/devcontainer/src/runtime/container.rs b/cmd/devcontainer/src/runtime/container.rs index 0c875e673..ed9c10919 100644 --- a/cmd/devcontainer/src/runtime/container.rs +++ b/cmd/devcontainer/src/runtime/container.rs @@ -5,8 +5,10 @@ mod engine_run; mod uid_update; use std::collections::HashMap; +use std::path::Path; use super::lifecycle::LifecycleMode; +use crate::commands::common; pub(crate) use discovery::{ ensure_up_container, probe_up_container_id_labels, resolve_target_container_match, @@ -23,3 +25,20 @@ pub(crate) struct UpContainer { pub(crate) lifecycle_mode: LifecycleMode, pub(crate) matched_id_labels: Option>, } + +pub(crate) fn dev_container_not_found_message( + args: &[String], + workspace_folder: Option<&Path>, + config_file: Option<&Path>, +) -> String { + if common::parse_option_values(args, "--id-label").is_empty() { + if let (Some(workspace_folder), Some(config_file)) = (workspace_folder, config_file) { + return format!( + "Dev container not found for workspace folder '{}' and config file '{}'. If the container was created with a different config file, pass --config or set DEVCONTAINER_CONFIG.", + workspace_folder.display(), + config_file.display() + ); + } + } + "Dev container not found.".to_string() +} diff --git a/cmd/devcontainer/src/runtime/container/discovery.rs b/cmd/devcontainer/src/runtime/container/discovery.rs index fb985c27b..a900e4b84 100644 --- a/cmd/devcontainer/src/runtime/container/discovery.rs +++ b/cmd/devcontainer/src/runtime/container/discovery.rs @@ -119,7 +119,11 @@ fn ensure_compose_up_container( } if common::has_flag(args, "--expect-existing-container") { - return Err("Dev container not found.".to_string()); + return Err(super::dev_container_not_found_message( + args, + Some(&resolved.workspace_folder), + Some(&resolved.config_file), + )); } create_compose_container(resolved, args, image_name, remote_workspace_folder) @@ -167,7 +171,11 @@ fn ensure_engine_up_container( }) } None if common::has_flag(args, "--expect-existing-container") => { - Err("Dev container not found.".to_string()) + Err(super::dev_container_not_found_message( + args, + Some(&resolved.workspace_folder), + Some(&resolved.config_file), + )) } None => create_engine_container(resolved, args, image_name, remote_workspace_folder), }, @@ -327,7 +335,11 @@ pub(crate) fn resolve_target_container_match( match find_target_container(args, workspace_folder, config_file, false)? { Some(target) => Ok(target), - None => Err("Dev container not found.".to_string()), + None => Err(super::dev_container_not_found_message( + args, + workspace_folder, + config_file, + )), } } @@ -978,7 +990,15 @@ esac ) .expect_err("missing container should fail"); - assert_eq!(error, "Dev container not found."); + assert_eq!( + error, + "Dev container not found for workspace folder '/workspace' and config file '/workspace/.devcontainer/devcontainer.json'. If the container was created with a different config file, pass --config or set DEVCONTAINER_CONFIG." + ); + + let workspace_only_error = + resolve_target_container_match(&args, Some(Path::new("/workspace")), None) + .expect_err("missing workspace-only container should fail"); + assert_eq!(workspace_only_error, "Dev container not found."); let _ = fs::remove_dir_all(root); } @@ -1583,7 +1603,11 @@ esac ensure_up_container(&resolved, &expect_error_args, "alpine:3.20", "/workspace") .err() .expect("expect existing should reject missing containers"), - "Dev container not found." + format!( + "Dev container not found for workspace folder '{}' and config file '{}'. If the container was created with a different config file, pass --config or set DEVCONTAINER_CONFIG.", + resolved.workspace_folder.display(), + resolved.config_file.display() + ) ); let _ = fs::remove_dir_all(root); } @@ -2008,7 +2032,14 @@ esac let error = ensure_up_container(&resolved, &expect_args, "alpine:3.20", "/workspace") .err() .expect("expect existing should reject missing compose containers"); - assert_eq!(error, "Dev container not found."); + assert_eq!( + error, + format!( + "Dev container not found for workspace folder '{}' and config file '{}'. If the container was created with a different config file, pass --config or set DEVCONTAINER_CONFIG.", + resolved.workspace_folder.display(), + resolved.config_file.display() + ) + ); let up = ensure_up_container( &resolved, diff --git a/cmd/devcontainer/src/runtime/context.rs b/cmd/devcontainer/src/runtime/context.rs index 88d1b6de4..93cfd3f24 100644 --- a/cmd/devcontainer/src/runtime/context.rs +++ b/cmd/devcontainer/src/runtime/context.rs @@ -64,7 +64,7 @@ pub(crate) fn load_required_config_with_id_labels( } pub(crate) fn load_optional_config(args: &[String]) -> Result, String> { - let explicit_config = common::parse_option_value(args, "--config"); + let explicit_config = common::config_option_value(args); match load_required_config(args) { Ok(config) => Ok(Some(config)), Err(error) @@ -85,8 +85,13 @@ pub(crate) fn resolve_existing_container_context( if let Some(resolved) = &resolved { if explicit_container_id.is_none() && compose::uses_compose_config(&resolved.configuration) { - let container_id = compose::resolve_container_id(resolved, args)? - .ok_or_else(|| "Dev container not found.".to_string())?; + let container_id = compose::resolve_container_id(resolved, args)?.ok_or_else(|| { + container::dev_container_not_found_message( + args, + Some(&resolved.workspace_folder), + Some(&resolved.config_file), + ) + })?; let configuration = configuration_with_feature_metadata(args, resolved)?; return Ok(ExistingContainerContext { container_id, @@ -185,7 +190,9 @@ mod tests { use serde_json::json; - use crate::commands::common::DEVCONTAINER_LOCAL_FOLDER_LABEL; + use crate::commands::common::{ + test_env_defaults, DEVCONTAINER_CONFIG, DEVCONTAINER_LOCAL_FOLDER_LABEL, + }; use crate::runtime::mounts::split_mount_options; use crate::test_support::{unique_temp_dir, write_executable_script}; @@ -295,6 +302,17 @@ mod tests { assert!(error.starts_with("Unable to locate a dev container config at ")); + let missing_env_config = root.join("missing-env.json"); + let _env = test_env_defaults(&[( + DEVCONTAINER_CONFIG, + missing_env_config.to_string_lossy().as_ref(), + )]); + let error = + load_optional_config(&["--container-id".to_string(), "container-id".to_string()]) + .expect_err("environment missing config should fail"); + assert!(error.starts_with("Unable to locate a dev container config at ")); + assert!(error.contains("missing-env.json"), "{error}"); + let _ = fs::remove_dir_all(root); } @@ -395,7 +413,18 @@ mod tests { resolve_existing_container_context(&empty_args), "missing compose container", ); - assert_eq!(missing, "Dev container not found."); + assert_eq!( + missing, + format!( + "Dev container not found for workspace folder '{}' and config file '{}'. If the container was created with a different config file, pass --config or set DEVCONTAINER_CONFIG.", + fs::canonicalize(&root) + .expect("canonical workspace") + .display(), + fs::canonicalize(&config_file) + .expect("canonical config") + .display() + ) + ); let _ = fs::remove_dir_all(root); } @@ -489,7 +518,7 @@ mod tests { fn resolve_existing_container_context_reports_missing_matched_container() { let root = unique_temp_dir("devcontainer-runtime-context"); fs::create_dir_all(&root).expect("workspace"); - write_workspace_config(&root, "{\n \"image\": \"alpine:3.20\"\n}\n"); + let config_file = write_workspace_config(&root, "{\n \"image\": \"alpine:3.20\"\n}\n"); let engine = root.join("engine"); write_engine_script( &engine, @@ -501,7 +530,21 @@ mod tests { let error = resolve_existing_container_context(&args).expect_err("missing container should fail"); - assert_eq!(error, "Dev container not found."); + assert_eq!( + error, + format!( + "Dev container not found for workspace folder '{}' and config file '{}'. If the container was created with a different config file, pass --config or set DEVCONTAINER_CONFIG.", + fs::canonicalize(&root) + .expect("canonical workspace") + .display(), + config_file.display() + ) + ); + + args.extend(["--id-label".to_string(), "custom=value".to_string()]); + let custom_label_error = resolve_existing_container_context(&args) + .expect_err("missing custom-label container should fail"); + assert_eq!(custom_label_error, "Dev container not found."); let _ = fs::remove_dir_all(root); } diff --git a/cmd/devcontainer/tests/runtime_configuration_smoke.rs b/cmd/devcontainer/tests/runtime_configuration_smoke.rs index a0400d966..d8dea7c78 100644 --- a/cmd/devcontainer/tests/runtime_configuration_smoke.rs +++ b/cmd/devcontainer/tests/runtime_configuration_smoke.rs @@ -7,6 +7,72 @@ use std::path::Path; use support::runtime_harness::{write_devcontainer_config, RuntimeHarness}; +#[test] +fn read_configuration_uses_environment_config_path() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let default_config = workspace.join(".devcontainer").join("devcontainer.json"); + let env_config = workspace + .join(".devcontainer") + .join("podman") + .join("devcontainer.json"); + fs::create_dir_all(default_config.parent().expect("default config parent")) + .expect("default config dir"); + fs::create_dir_all(env_config.parent().expect("env config parent")).expect("env config dir"); + fs::write(&default_config, r#"{"image":"default"}"#).expect("default config"); + fs::write(&env_config, r#"{"image":"environment"}"#).expect("environment config"); + + let output = harness.run( + &[ + "read-configuration", + "--workspace-folder", + workspace.to_string_lossy().as_ref(), + ], + &[( + "DEVCONTAINER_CONFIG", + ".devcontainer/podman/devcontainer.json", + )], + ); + + assert!(output.status.success(), "{output:?}"); + let payload = harness.parse_stdout_json(&output); + assert_eq!(payload["configuration"]["image"], "environment"); + assert_eq!( + payload["configuration"]["configFilePath"], + fs::canonicalize(&env_config) + .expect("canonical environment config") + .display() + .to_string() + ); +} + +#[test] +fn read_configuration_resolves_relative_environment_config_from_current_directory() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let env_config = workspace.join("configs").join("devcontainer.json"); + fs::create_dir_all(env_config.parent().expect("environment config parent")) + .expect("environment config dir"); + fs::write(&env_config, r#"{"image":"environment"}"#).expect("environment config"); + + let output = harness.run_in_dir( + &["read-configuration"], + &[("DEVCONTAINER_CONFIG", "configs/devcontainer.json")], + Some(&workspace), + ); + + assert!(output.status.success(), "{output:?}"); + let payload = harness.parse_stdout_json(&output); + assert_eq!(payload["configuration"]["image"], "environment"); + assert_eq!( + payload["configuration"]["configFilePath"], + fs::canonicalize(&env_config) + .expect("canonical environment config") + .display() + .to_string() + ); +} + #[test] fn read_configuration_with_container_id_merges_config_and_container_metadata() { let harness = RuntimeHarness::new(); diff --git a/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs b/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs index 95ef3a523..2b7846645 100644 --- a/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs +++ b/cmd/devcontainer/tests/runtime_container_smoke/compose_flow.rs @@ -547,10 +547,12 @@ fn up_expect_existing_compose_container_fails_when_missing() { "services:\n app:\n image: alpine:3.20\n", ) .expect("compose"); - write_devcontainer_config( + let config_file = write_devcontainer_config( &workspace, "{\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\",\n \"workspaceFolder\": \"/workspace\"\n}\n", ); + let expected_workspace = fs::canonicalize(&workspace).expect("canonical workspace"); + let expected_config = fs::canonicalize(&config_file).expect("canonical config"); let fake_podman = harness.fake_podman.to_string_lossy().to_string(); let output = harness.run( @@ -570,7 +572,11 @@ fn up_expect_existing_compose_container_fails_when_missing() { String::from_utf8(output.stderr) .expect("utf8 stderr") .trim(), - "Dev container not found." + format!( + "Dev container not found for workspace folder '{}' and config file '{}'. If the container was created with a different config file, pass --config or set DEVCONTAINER_CONFIG.", + expected_workspace.display(), + expected_config.display() + ) ); } diff --git a/cmd/devcontainer/tests/runtime_container_smoke/reuse.rs b/cmd/devcontainer/tests/runtime_container_smoke/reuse.rs index 61aa7d75e..2126e19ab 100644 --- a/cmd/devcontainer/tests/runtime_container_smoke/reuse.rs +++ b/cmd/devcontainer/tests/runtime_container_smoke/reuse.rs @@ -207,7 +207,9 @@ fn up_expect_existing_container_fails_when_missing() { 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 config_file = write_devcontainer_config(&workspace, "{\n \"image\": \"alpine:3.20\"\n}\n"); + let expected_workspace = fs::canonicalize(&workspace).expect("canonical workspace"); + let expected_config = fs::canonicalize(&config_file).expect("canonical config"); let fake_podman = harness.fake_podman.to_string_lossy().to_string(); let output = harness.run( @@ -227,7 +229,11 @@ fn up_expect_existing_container_fails_when_missing() { String::from_utf8(output.stderr) .expect("utf8 stderr") .trim(), - "Dev container not found." + format!( + "Dev container not found for workspace folder '{}' and config file '{}'. If the container was created with a different config file, pass --config or set DEVCONTAINER_CONFIG.", + expected_workspace.display(), + expected_config.display() + ) ); } diff --git a/cmd/devcontainer/tests/runtime_context_smoke.rs b/cmd/devcontainer/tests/runtime_context_smoke.rs index 9b7093440..07bbb1087 100644 --- a/cmd/devcontainer/tests/runtime_context_smoke.rs +++ b/cmd/devcontainer/tests/runtime_context_smoke.rs @@ -108,6 +108,117 @@ fn nested_config_exec_uses_workspace_root_and_config_label() { assert!(invocations.contains("fake-container-id /bin/echo hello-from-nested-config")); } +#[test] +fn environment_config_exec_uses_nested_config_identity() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let nested_config_dir = workspace.join(".devcontainer").join("podman"); + fs::create_dir_all(&nested_config_dir).expect("nested config dir"); + let config_path = nested_config_dir.join("devcontainer.json"); + fs::write( + &config_path, + "{\n \"image\": \"alpine:3.20\",\n \"workspaceFolder\": \"/environment-workspace\"\n}\n", + ) + .expect("config write"); + let expected_workspace = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.clone()); + let expected_config = config_path + .canonicalize() + .unwrap_or_else(|_| config_path.clone()); + let required_labels = format!( + "devcontainer.local_folder={}\ndevcontainer.config_file={}", + expected_workspace.display(), + expected_config.display() + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run_in_dir( + &[ + "exec", + "--docker-path", + fake_podman.as_str(), + "/bin/echo", + "hello-from-environment-config", + ], + &[ + ( + "DEVCONTAINER_CONFIG", + ".devcontainer/podman/devcontainer.json", + ), + ("FAKE_PODMAN_PS_REQUIRE_LABELS", required_labels.as_str()), + ], + Some(&workspace), + ); + + assert!(output.status.success(), "{output:?}"); + assert_eq!( + String::from_utf8(output.stdout).expect("utf8 stdout"), + "hello-from-environment-config\n" + ); + let invocations = harness.read_invocations(); + assert!(invocations.contains(&format!( + "--filter label=devcontainer.config_file={}", + expected_config.display() + ))); + assert!(invocations.contains("exec -i --workdir /environment-workspace")); +} + +#[test] +fn exec_reports_attempted_workspace_and_config_when_labels_do_not_match() { + let harness = RuntimeHarness::new(); + let workspace = harness.workspace(); + let default_config = + write_devcontainer_config(&workspace, "{\n \"image\": \"alpine:3.20\"\n}\n"); + let expected_workspace = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.clone()); + let expected_config = default_config + .canonicalize() + .unwrap_or_else(|_| default_config.clone()); + let actual_config = expected_workspace + .join(".devcontainer") + .join("podman") + .join("devcontainer.json"); + let actual_labels = format!( + "devcontainer.local_folder={}\ndevcontainer.config_file={}", + expected_workspace.display(), + actual_config.display() + ); + + let fake_podman = harness.fake_podman.to_string_lossy().to_string(); + let output = harness.run_in_dir( + &[ + "exec", + "--docker-path", + fake_podman.as_str(), + "/bin/echo", + "unreachable", + ], + &[("FAKE_PODMAN_PS_REQUIRE_LABELS", actual_labels.as_str())], + Some(&workspace), + ); + + assert!(!output.status.success(), "{output:?}"); + assert_eq!( + String::from_utf8(output.stderr).expect("utf8 stderr"), + format!( + "Dev container not found for workspace folder '{}' and config file '{}'. If the container was created with a different config file, pass --config or set DEVCONTAINER_CONFIG.\n", + expected_workspace.display(), + expected_config.display() + ) + ); + let invocations = harness.read_invocations(); + assert_eq!( + invocations + .lines() + .filter(|line| line.starts_with("ps ")) + .count(), + 1, + "diagnostic must not add discovery probes: {invocations}" + ); +} + #[test] fn exec_from_workspace_directory_loads_local_config() { let harness = RuntimeHarness::new(); diff --git a/docs/upstream/parity-inventory.json b/docs/upstream/parity-inventory.json index 9d030d47b..2293fe069 100644 --- a/docs/upstream/parity-inventory.json +++ b/docs/upstream/parity-inventory.json @@ -65,10 +65,9 @@ "name": "config", "sourceReferenced": true, "evidence": [ - "cmd/devcontainer/src/commands/configuration/load.rs", "cmd/devcontainer/src/commands/configuration/read.rs", "cmd/devcontainer/src/commands/configuration/upgrade.rs", - "cmd/devcontainer/src/runtime/context.rs", + "cmd/devcontainer/src/runtime/container.rs", "cmd/devcontainer/src/runtime/exec.rs" ] }, @@ -186,6 +185,7 @@ "evidence": [ "cmd/devcontainer/src/commands/configuration/read.rs", "cmd/devcontainer/src/runtime/compose/override_file.rs", + "cmd/devcontainer/src/runtime/container.rs", "cmd/devcontainer/src/runtime/container/discovery.rs", "cmd/devcontainer/src/runtime/container/engine_run.rs", "cmd/devcontainer/src/runtime/exec.rs" @@ -408,7 +408,7 @@ "name": "config", "sourceReferenced": true, "evidence": [ - "cmd/devcontainer/src/runtime/context.rs", + "cmd/devcontainer/src/runtime/container.rs", "cmd/devcontainer/src/runtime/exec.rs" ] }, @@ -604,10 +604,9 @@ "name": "config", "sourceReferenced": true, "evidence": [ - "cmd/devcontainer/src/commands/configuration/load.rs", "cmd/devcontainer/src/commands/configuration/read.rs", "cmd/devcontainer/src/commands/configuration/upgrade.rs", - "cmd/devcontainer/src/runtime/context.rs", + "cmd/devcontainer/src/runtime/container.rs", "cmd/devcontainer/src/runtime/exec.rs" ] }, @@ -791,7 +790,7 @@ "name": "config", "sourceReferenced": true, "evidence": [ - "cmd/devcontainer/src/runtime/context.rs", + "cmd/devcontainer/src/runtime/container.rs", "cmd/devcontainer/src/runtime/exec.rs" ] }, @@ -877,6 +876,7 @@ "sourceReferenced": true, "evidence": [ "cmd/devcontainer/src/runtime/compose/override_file.rs", + "cmd/devcontainer/src/runtime/container.rs", "cmd/devcontainer/src/runtime/container/discovery.rs", "cmd/devcontainer/src/runtime/container/engine_run.rs", "cmd/devcontainer/src/runtime/exec.rs" @@ -1021,10 +1021,9 @@ "name": "config", "sourceReferenced": true, "evidence": [ - "cmd/devcontainer/src/commands/configuration/load.rs", "cmd/devcontainer/src/commands/configuration/read.rs", "cmd/devcontainer/src/commands/configuration/upgrade.rs", - "cmd/devcontainer/src/runtime/context.rs", + "cmd/devcontainer/src/runtime/container.rs", "cmd/devcontainer/src/runtime/exec.rs" ] }, @@ -1065,6 +1064,7 @@ "evidence": [ "cmd/devcontainer/src/commands/configuration/read.rs", "cmd/devcontainer/src/runtime/compose/override_file.rs", + "cmd/devcontainer/src/runtime/container.rs", "cmd/devcontainer/src/runtime/container/discovery.rs", "cmd/devcontainer/src/runtime/container/engine_run.rs", "cmd/devcontainer/src/runtime/exec.rs" @@ -1192,7 +1192,6 @@ "name": "config", "sourceReferenced": true, "evidence": [ - "cmd/devcontainer/src/commands/configuration/load.rs", "cmd/devcontainer/src/commands/configuration/read.rs", "cmd/devcontainer/src/commands/configuration/upgrade.rs" ] @@ -1272,7 +1271,6 @@ "name": "config", "sourceReferenced": true, "evidence": [ - "cmd/devcontainer/src/commands/configuration/load.rs", "cmd/devcontainer/src/commands/configuration/read.rs", "cmd/devcontainer/src/commands/configuration/upgrade.rs" ] @@ -1792,7 +1790,7 @@ "name": "config", "sourceReferenced": true, "evidence": [ - "cmd/devcontainer/src/runtime/context.rs", + "cmd/devcontainer/src/runtime/container.rs", "cmd/devcontainer/src/runtime/exec.rs" ] }, @@ -1847,6 +1845,7 @@ "sourceReferenced": true, "evidence": [ "cmd/devcontainer/src/runtime/compose/override_file.rs", + "cmd/devcontainer/src/runtime/container.rs", "cmd/devcontainer/src/runtime/container/discovery.rs", "cmd/devcontainer/src/runtime/container/engine_run.rs", "cmd/devcontainer/src/runtime/exec.rs"