Fix 4 spec compliance gaps in arc-devcontainer

- Stop baking remoteEnv into Dockerfile (only containerEnv belongs as ENV)
- Merge forwardPorts with compose ports in compose mode (with dedup)
- Support build.target (parsed with variable substitution)
- Handle forwardPorts string formats like "8080:80" and "9090"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-03 00:06:23 -05:00
parent 3b2c0de11a
commit 4ad60489b9
11 changed files with 176 additions and 74 deletions

View file

@ -8,17 +8,17 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https
| Property | Status | Notes |
|---|---|---|
| `name` | No | Parsed by serde (ignored via `#[serde(default)]`); not exposed in `DevcontainerConfig` |
| `forwardPorts` | Yes | Numeric ports extracted into `DevcontainerConfig::forwarded_ports`; string formats ignored |
| `name` | No | Silently ignored by serde (unknown fields are skipped); not exposed in `DevcontainerConfig` |
| `forwardPorts` | Yes | Numeric and string formats (e.g., `"8080:80"`, `"9090"`) extracted into `DevcontainerConfig::forwarded_ports`; merged with compose ports in compose mode |
| `portsAttributes` | No | Not parsed |
| `otherPortsAttributes` | No | Not parsed |
| `updateRemoteUserUID` | No | Not parsed |
| `containerEnv` | Yes | Baked into generated Dockerfile as `ENV` directives; also exposed in `DevcontainerConfig::container_env` |
| `remoteEnv` | Yes | Merged into `DevcontainerConfig::environment` with variable substitution |
| `containerUser` | Partial | Parsed in `DevcontainerJson::container_user` but not exposed in `DevcontainerConfig` |
| `containerUser` | No | Parsed but unused; not exposed in `DevcontainerConfig` |
| `remoteUser` | Yes | Exposed as `DevcontainerConfig::remote_user` |
| `userEnvProbe` | No | Not parsed |
| `overrideCommand` | Partial | Parsed in `DevcontainerJson::override_command` but not acted on |
| `overrideCommand` | No | Parsed but unused |
| `shutdownAction` | No | Not parsed |
## Image
@ -34,7 +34,7 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https
| `build.dockerfile` | Yes | Resolved relative to devcontainer.json; content read and used as base Dockerfile |
| `build.context` | Yes | Resolved with variable substitution; passed as `DevcontainerConfig::build_context` |
| `build.args` | Yes | Parsed and exposed in `DevcontainerConfig::build_args` for passing to `docker build --build-arg` |
| `build.target` | No | Not parsed |
| `build.target` | Yes | Parsed with variable substitution; exposed as `DevcontainerConfig::build_target` for passing to `docker build --target` |
| `build.cacheFrom` | No | Not parsed |
| `build.options` | No | Not parsed |
@ -46,9 +46,9 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https
| `service` | Yes | Required when `dockerComposeFile` is set; used to extract service config |
| `runServices` | No | Not parsed; all services assumed |
| `shutdownAction` | No | Not parsed |
| `overrideCommand` | Partial | Parsed but not acted on in compose mode |
| `overrideCommand` | No | Parsed but unused |
| `workspaceFolder` | Yes | Defaults to `/workspaces/{repo-name}` |
| `workspaceMount` | Partial | Parsed in `DevcontainerJson::workspace_mount` but not used |
| `workspaceMount` | No | Parsed but unused |
## Features

View file

@ -11,6 +11,7 @@ pub struct DevcontainerConfig {
pub dockerfile: String, // Generated Dockerfile content
pub build_context: PathBuf, // Directory for docker build
pub build_args: HashMap<String, String>, // docker build --build-arg flags
pub build_target: Option<String>, // docker build --target
pub initialize_commands: Vec<Command>, // Host-side pre-build commands
pub on_create_commands: Vec<Command>, // Container after first creation
pub post_create_commands: Vec<Command>, // Container post-creation setup
@ -36,9 +37,9 @@ pub struct DevcontainerConfig {
### Environment Variables
`config.environment` contains the merged `remoteEnv` values (with variables already substituted). `config.container_env` contains `containerEnv` values (also baked into the generated Dockerfile as `ENV` directives).
`config.environment` contains the `remoteEnv` values (with variables already substituted). These are runtime-only environment variables, not baked into the Dockerfile. `config.container_env` contains `containerEnv` values (baked into the generated Dockerfile as `ENV` directives).
- Pass `config.environment` as environment variables when creating the sandbox.
- Pass `config.environment` as runtime environment variables when starting the sandbox.
- `containerEnv` values are already in the Dockerfile; `config.container_env` is available for reference.
### Workspace Folder
@ -187,4 +188,4 @@ async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result<Sandbox> {
- **No `updateContentCommand`**: This lifecycle hook is not parsed.
- **No `postAttachCommand`**: Not parsed. Attach-time hooks would need to run on each user session connection.
- **`${containerEnv:VAR}` not supported**: Variable substitution only covers host-side variables. Container-side env vars require a running container.
- **Port forwarding is numeric only**: String port formats (e.g., `"label:3000"`) in `forwardPorts` are filtered out; only numeric values are extracted.
- **Port forwarding**: Both numeric and string port formats (e.g., `"8080:80"`, `"9090"`) are supported in `forwardPorts`. In compose mode, `forwardPorts` are merged with compose service ports.

View file

@ -7,7 +7,6 @@ pub fn generate(
base_dockerfile: &str,
feature_layers: &[FeatureLayer],
container_env: &Option<HashMap<String, String>>,
remote_env: &Option<HashMap<String, String>>,
remote_user: Option<&str>,
) -> String {
let mut sections: Vec<String> = Vec::new();
@ -19,27 +18,17 @@ pub fn generate(
sections.push(layer.dockerfile_snippet.clone());
}
// containerEnv first, then remoteEnv (remoteEnv overrides on key collision)
let mut merged_env: HashMap<&String, &String> = HashMap::new();
if let Some(env) = container_env {
for (k, v) in env {
merged_env.insert(k, v);
if !env.is_empty() {
let mut keys: Vec<&String> = env.keys().collect();
keys.sort();
let env_lines: Vec<String> = keys
.iter()
.map(|k| format!("ENV {}={}", k, env[*k]))
.collect();
sections.push(env_lines.join("\n"));
}
}
if let Some(env) = remote_env {
for (k, v) in env {
merged_env.insert(k, v);
}
}
if !merged_env.is_empty() {
let mut keys: Vec<&&String> = merged_env.keys().collect();
keys.sort();
let env_lines: Vec<String> = keys
.iter()
.map(|k| format!("ENV {}={}", k, merged_env[*k]))
.collect();
sections.push(env_lines.join("\n"));
}
if let Some(user) = remote_user {
sections.push(format!("USER {}", user));
@ -64,7 +53,7 @@ mod tests {
#[test]
fn base_image_only() {
let result = generate("FROM ubuntu:22.04", &[], &None, &None, None);
let result = generate("FROM ubuntu:22.04", &[], &None, None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\nFROM ubuntu:22.04\n"
@ -74,7 +63,7 @@ mod tests {
#[test]
fn base_dockerfile_preserved_as_is() {
let base = "FROM ubuntu:22.04\nRUN apt-get update\nRUN apt-get install -y curl";
let result = generate(base, &[], &None, &None, None);
let result = generate(base, &[], &None, None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\nFROM ubuntu:22.04\nRUN apt-get update\nRUN apt-get install -y curl\n"
@ -87,7 +76,7 @@ mod tests {
make_layer("node", "node-1", "RUN install-node.sh"),
make_layer("python", "python-1", "RUN install-python.sh"),
];
let result = generate("FROM ubuntu:22.04", &layers, &None, &None, None);
let result = generate("FROM ubuntu:22.04", &layers, &None, None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
@ -103,7 +92,7 @@ mod tests {
env.insert("ZEBRA".to_string(), "stripes".to_string());
env.insert("APPLE".to_string(), "red".to_string());
env.insert("MANGO".to_string(), "yellow".to_string());
let result = generate("FROM alpine", &[], &None, &Some(env), None);
let result = generate("FROM alpine", &[], &Some(env), None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
@ -116,7 +105,7 @@ mod tests {
#[test]
fn with_remote_user() {
let result = generate("FROM alpine", &[], &None, &None, Some("vscode"));
let result = generate("FROM alpine", &[], &None, Some("vscode"));
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
@ -131,7 +120,7 @@ mod tests {
let mut env = HashMap::new();
env.insert("PATH".to_string(), "/usr/local/bin".to_string());
env.insert("HOME".to_string(), "/home/vscode".to_string());
let result = generate("FROM ubuntu:22.04", &layers, &None, &Some(env), Some("vscode"));
let result = generate("FROM ubuntu:22.04", &layers, &Some(env), Some("vscode"));
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
@ -145,7 +134,7 @@ mod tests {
#[test]
fn empty_feature_layers_no_extra_blank_lines() {
let result = generate("FROM alpine", &[], &None, &None, Some("dev"));
let result = generate("FROM alpine", &[], &None, Some("dev"));
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
@ -157,7 +146,7 @@ mod tests {
#[test]
fn empty_env_map_treated_as_none() {
let env = HashMap::new();
let result = generate("FROM alpine", &[], &None, &Some(env), None);
let result = generate("FROM alpine", &[], &Some(env), None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\nFROM alpine\n"
@ -173,7 +162,7 @@ mod tests {
\n\
FROM ubuntu:22.04\n\
COPY --from=builder /app/bin /usr/local/bin";
let result = generate(base, &[], &None, &None, None);
let result = generate(base, &[], &None, None);
assert!(result.starts_with("# Generated by arc-devcontainer\n\n"));
assert!(result.contains("FROM ubuntu:22.04 AS builder"));
assert!(result.contains("COPY --from=builder /app/bin /usr/local/bin"));
@ -184,7 +173,7 @@ mod tests {
fn container_env_only() {
let mut cenv = HashMap::new();
cenv.insert("DEBIAN_FRONTEND".to_string(), "noninteractive".to_string());
let result = generate("FROM alpine", &[], &Some(cenv), &None, None);
let result = generate("FROM alpine", &[], &Some(cenv), None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
@ -194,17 +183,14 @@ mod tests {
}
#[test]
fn container_env_and_remote_env_merged() {
fn container_env_with_multiple_keys() {
let mut cenv = HashMap::new();
cenv.insert("SHARED".to_string(), "from_container".to_string());
cenv.insert("ONLY_CONTAINER".to_string(), "yes".to_string());
let mut renv = HashMap::new();
renv.insert("SHARED".to_string(), "from_remote".to_string());
renv.insert("ONLY_REMOTE".to_string(), "yes".to_string());
let result = generate("FROM alpine", &[], &Some(cenv), &Some(renv), None);
// remoteEnv overrides containerEnv on collision
assert!(result.contains("ENV SHARED=from_remote"));
assert!(result.contains("ENV ONLY_CONTAINER=yes"));
assert!(result.contains("ENV ONLY_REMOTE=yes"));
cenv.insert("ALPHA".to_string(), "first".to_string());
cenv.insert("BETA".to_string(), "second".to_string());
cenv.insert("GAMMA".to_string(), "third".to_string());
let result = generate("FROM alpine", &[], &Some(cenv), None);
assert!(result.contains("ENV ALPHA=first"));
assert!(result.contains("ENV BETA=second"));
assert!(result.contains("ENV GAMMA=third"));
}
}

View file

@ -27,6 +27,8 @@ pub struct DevcontainerConfig {
pub build_context: PathBuf,
/// Build arguments (docker build --build-arg)
pub build_args: HashMap<String, String>,
/// Multi-stage build target (docker build --target)
pub build_target: Option<String>,
/// Run on host before build
pub initialize_commands: Vec<Command>,
/// Run in container after first creation (before updateContentCommand)
@ -173,6 +175,7 @@ impl DevcontainerResolver {
dockerfile,
build_context: compose_base_dir.to_path_buf(),
build_args: HashMap::new(),
build_target: None,
initialize_commands: Self::collect_commands(&devcontainer.initialize_command, &vars),
on_create_commands: Self::collect_commands(
&devcontainer.on_create_command,
@ -193,14 +196,22 @@ impl DevcontainerResolver {
.clone()
.or(compose_config.user),
workspace_folder,
forwarded_ports: compose_config.ports,
forwarded_ports: {
let mut ports = compose_config.ports;
for port in Self::parse_forward_ports(&devcontainer.forward_ports) {
if !ports.contains(&port) {
ports.push(port);
}
}
ports
},
compose_files: compose_paths,
compose_service: Some(service_name),
});
}
// Image or Dockerfile mode
let (base_dockerfile, build_context, build_args) = if let Some(build) =
let (base_dockerfile, build_context, build_args, build_target) = if let Some(build) =
&devcontainer.build
{
let context_dir = build
@ -223,13 +234,17 @@ impl DevcontainerResolver {
.iter()
.map(|(k, v)| (k.clone(), variables::substitute(v, &vars)))
.collect();
(content, context_dir, args)
let target = build
.target
.as_ref()
.map(|t| variables::substitute(t, &vars));
(content, context_dir, args, target)
} else {
let image = devcontainer
.image
.as_deref()
.unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu");
(format!("FROM {image}"), base_dir.to_path_buf(), HashMap::new())
(format!("FROM {image}"), base_dir.to_path_buf(), HashMap::new(), None)
};
// Features
@ -244,7 +259,6 @@ impl DevcontainerResolver {
&base_dockerfile,
&feature_layers,
&devcontainer.container_env,
&devcontainer.remote_env,
devcontainer.remote_user.as_deref(),
);
@ -255,19 +269,13 @@ impl DevcontainerResolver {
}
}
let forwarded_ports = devcontainer
.forward_ports
.iter()
.filter_map(|p| match p {
serde_json::Value::Number(n) => n.as_u64().map(|n| n as u16),
_ => None,
})
.collect();
let forwarded_ports = Self::parse_forward_ports(&devcontainer.forward_ports);
Ok(DevcontainerConfig {
dockerfile: dockerfile_content,
build_context,
build_args,
build_target,
initialize_commands: Self::collect_commands(&devcontainer.initialize_command, &vars),
on_create_commands: Self::collect_commands(&devcontainer.on_create_command, &vars),
post_create_commands: Self::collect_commands(&devcontainer.post_create_command, &vars),
@ -374,4 +382,22 @@ impl DevcontainerResolver {
}
}
}
fn parse_forward_ports(ports: &[serde_json::Value]) -> Vec<u16> {
ports
.iter()
.filter_map(|p| match p {
serde_json::Value::Number(n) => n.as_u64().map(|n| n as u16),
serde_json::Value::String(s) => {
let s = s.split('/').next().unwrap_or(s); // strip protocol
if let Some((_host, container)) = s.split_once(':') {
container.parse::<u16>().ok()
} else {
s.parse::<u16>().ok()
}
}
_ => None,
})
.collect()
}
}

View file

@ -73,6 +73,9 @@ pub struct BuildConfig {
/// Build arguments
#[serde(default)]
pub args: HashMap<String, String>,
/// Multi-stage build target
pub target: Option<String>,
}
/// A reference to one or more Docker Compose files.
@ -189,7 +192,8 @@ mod tests {
"build": {
"dockerfile": "Dockerfile",
"context": "..",
"args": {"VARIANT": "3.9"}
"args": {"VARIANT": "3.9"},
"target": "dev"
}
}"#;
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
@ -197,6 +201,7 @@ mod tests {
assert_eq!(build.dockerfile.as_deref(), Some("Dockerfile"));
assert_eq!(build.context.as_deref(), Some(".."));
assert_eq!(build.args.get("VARIANT").map(String::as_str), Some("3.9"));
assert_eq!(build.target.as_deref(), Some("dev"));
}
#[test]

View file

@ -36,9 +36,8 @@ async fn realistic_python_project() {
Some("1")
);
// Gap 3: remoteEnv overrides containerEnv on key collision (PYTHONUNBUFFERED)
// In the Dockerfile, remoteEnv value "yes" should win over containerEnv value "1"
assert!(config.dockerfile.contains("ENV PYTHONUNBUFFERED=yes"));
// After fix: only containerEnv is baked into Dockerfile (remoteEnv is runtime-only)
assert!(config.dockerfile.contains("ENV PYTHONUNBUFFERED=1"));
// environment HashMap gets the remoteEnv value
assert_eq!(
config.environment.get("PYTHONUNBUFFERED").map(String::as_str),
@ -92,11 +91,12 @@ async fn realistic_compose_project() {
// Gap 4: image from base compose file (override doesn't change image)
assert!(config.dockerfile.contains("FROM node:20-bookworm"));
// Gap 4: ports merged from both files (base: 3000, 9229; override: 4000)
// Ports merged from both compose files (base: 3000, 9229; override: 4000) + forwardPorts (8080)
assert!(config.forwarded_ports.contains(&3000));
assert!(config.forwarded_ports.contains(&9229));
assert!(config.forwarded_ports.contains(&4000));
assert_eq!(config.forwarded_ports.len(), 3);
assert!(config.forwarded_ports.contains(&8080));
assert_eq!(config.forwarded_ports.len(), 4);
// Gap 4: environment merged from both compose files + remoteEnv
assert_eq!(
@ -207,6 +207,84 @@ async fn build_args_empty_in_image_and_compose_modes() {
assert!(compose_config.build_args.is_empty());
}
/// Verify build_target is None for image-only and compose modes.
#[tokio::test]
async fn build_target_none_in_image_and_compose_modes() {
let image_config = DevcontainerResolver::resolve(&fixture_path("image-only"))
.await
.unwrap();
assert!(image_config.build_target.is_none());
let compose_config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
.await
.unwrap();
assert!(compose_config.build_target.is_none());
}
/// Gap 1: remoteEnv values must NOT appear as ENV directives in the generated Dockerfile.
/// Only containerEnv should be baked in.
#[tokio::test]
async fn remote_env_excluded_from_dockerfile() {
// image-only fixture has remoteEnv: {"EDITOR": "code"} and containerEnv: {"DEBIAN_FRONTEND": "noninteractive"}
let config = DevcontainerResolver::resolve(&fixture_path("image-only"))
.await
.unwrap();
// containerEnv IS in the Dockerfile
assert!(config.dockerfile.contains("ENV DEBIAN_FRONTEND=noninteractive"));
// remoteEnv is NOT in the Dockerfile
assert!(!config.dockerfile.contains("EDITOR=code"));
// remoteEnv IS in the environment HashMap (runtime-only)
assert_eq!(
config.environment.get("EDITOR").map(String::as_str),
Some("code")
);
}
/// Gap 2: forwardPorts in compose mode are merged with compose service ports, with deduplication.
#[tokio::test]
async fn forward_ports_merged_and_deduped_in_compose() {
// compose-mode fixture has compose ports [3000, 9229] and forwardPorts [3000, 5173]
let config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
.await
.unwrap();
// 3000 appears in both compose ports and forwardPorts — should NOT be duplicated
assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]);
}
/// Gap 3: build.target is parsed and exposed in dockerfile mode.
#[tokio::test]
async fn build_target_in_dockerfile_mode() {
let config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode"))
.await
.unwrap();
assert_eq!(config.build_target.as_deref(), Some("dev"));
}
/// Gap 4: forwardPorts string formats ("host:container", "port") are parsed correctly.
#[tokio::test]
async fn forward_ports_string_formats() {
// image-only fixture has forwardPorts: [3000, "8080:80", "9090"]
let config = DevcontainerResolver::resolve(&fixture_path("image-only"))
.await
.unwrap();
// 3000 is a plain number
assert!(config.forwarded_ports.contains(&3000));
// "8080:80" extracts container port 80
assert!(config.forwarded_ports.contains(&80));
// "9090" is parsed as a plain port number
assert!(config.forwarded_ports.contains(&9090));
// host port 8080 should NOT appear (only container port matters)
assert!(!config.forwarded_ports.contains(&8080));
assert_eq!(config.forwarded_ports, vec![3000, 80, 9090]);
}
/// Verify compose_files is empty for non-compose modes.
#[tokio::test]
async fn compose_files_empty_in_non_compose_modes() {

View file

@ -3,6 +3,7 @@
"service": "app",
"workspaceFolder": "/workspace",
"remoteUser": "node",
"forwardPorts": [3000, 5173],
"postCreateCommand": "npm install",
"remoteEnv": {
"NODE_ENV": "development"

View file

@ -3,7 +3,8 @@
"build": {
"dockerfile": "Dockerfile",
"context": "..",
"args": {"NODE_VERSION": "20"}
"args": {"NODE_VERSION": "20"},
"target": "dev"
},
"remoteUser": "developer",
"postCreateCommand": "npm install",

View file

@ -1,6 +1,6 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"forwardPorts": [3000, 8080],
"forwardPorts": [3000, "8080:80", "9090"],
"remoteUser": "vscode",
"remoteEnv": {
"EDITOR": "code"

View file

@ -3,6 +3,7 @@
"service": "app",
"workspaceFolder": "/workspace",
"remoteUser": "node",
"forwardPorts": [8080],
"containerEnv": {
"TERM": "xterm-256color",
"EDITOR": "vim"

View file

@ -15,7 +15,7 @@ async fn resolve_image_only() {
assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu"));
assert_eq!(config.remote_user.as_deref(), Some("vscode"));
assert_eq!(config.forwarded_ports, vec![3000, 8080]);
assert_eq!(config.forwarded_ports, vec![3000, 80, 9090]);
assert_eq!(config.environment.get("EDITOR").map(String::as_str), Some("code"));
assert_eq!(config.workspace_folder, "/workspaces/image-only");
assert!(config.compose_files.is_empty());
@ -53,6 +53,9 @@ async fn resolve_dockerfile_mode() {
// build.args
assert_eq!(config.build_args.get("NODE_VERSION").map(String::as_str), Some("20"));
// build.target
assert_eq!(config.build_target.as_deref(), Some("dev"));
}
#[tokio::test]
@ -68,8 +71,8 @@ async fn resolve_compose_mode() {
assert_eq!(config.compose_files.len(), 1);
assert_eq!(config.compose_service.as_deref(), Some("app"));
// Ports come from compose + remoteEnv merged
assert_eq!(config.forwarded_ports, vec![3000, 9229]);
// Ports come from compose + forwardPorts merged
assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]);
// Environment merged from compose + remoteEnv
assert_eq!(config.environment.get("NODE_ENV").map(String::as_str), Some("development"));