Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
16 changes: 8 additions & 8 deletions cmd/devcontainer/src/commands/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions cmd/devcontainer/src/commands/common/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<String> {
env_default_option_value(args, "--config", DEVCONTAINER_CONFIG)
}

pub(crate) fn env_default_choice_value(
args: &[String],
option: &str,
Expand Down
81 changes: 79 additions & 2 deletions cmd/devcontainer/src/commands/common/config_resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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()
Expand Down Expand Up @@ -193,14 +200,84 @@ 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::{
load_resolved_config, load_resolved_config_with_id_labels, resolve_override_config_path,
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");
Expand Down
12 changes: 11 additions & 1 deletion cmd/devcontainer/src/commands/configuration/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ pub(super) fn load_optional_config(args: &[String]) -> Result<Option<LoadedConfi

fn missing_config_is_optional_for_container_inspection(args: &[String], error: &str) -> 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 ")
}

#[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() {
Expand Down Expand Up @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions cmd/devcontainer/src/runtime/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,3 +25,20 @@ pub(crate) struct UpContainer {
pub(crate) lifecycle_mode: LifecycleMode,
pub(crate) matched_id_labels: Option<HashMap<String, String>>,
}

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 <path> or set DEVCONTAINER_CONFIG.",
workspace_folder.display(),
config_file.display()
);
}
}
"Dev container not found.".to_string()
}
43 changes: 37 additions & 6 deletions cmd/devcontainer/src/runtime/container/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
},
Expand Down Expand Up @@ -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,
)),
}
}

Expand Down Expand Up @@ -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 <path> 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);
}

Expand Down Expand Up @@ -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 <path> or set DEVCONTAINER_CONFIG.",
resolved.workspace_folder.display(),
resolved.config_file.display()
)
);
let _ = fs::remove_dir_all(root);
}
Expand Down Expand Up @@ -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 <path> or set DEVCONTAINER_CONFIG.",
resolved.workspace_folder.display(),
resolved.config_file.display()
)
);

let up = ensure_up_container(
&resolved,
Expand Down
Loading
Loading