Fix 4 critical gaps in arc-devcontainer: onCreateCommand, build.args, containerEnv, compose array

- Add onCreateCommand lifecycle hook (parsed, resolved, exposed as on_create_commands)
- Expose build.args on DevcontainerConfig for docker build --build-arg
- Wire containerEnv into generated Dockerfile as ENV directives (remoteEnv overrides on collision)
- Support dockerComposeFile as array of paths with merge semantics (last wins for image/build/user, ports accumulate, env overrides)
- Update DEVCONTAINER-COMPATIBILITY.md and INTEGRATION.md docs
- Add e2e tests with realistic Python and compose project fixtures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-02 23:43:45 -05:00
parent 74cd6bc791
commit ee11a46b86
19 changed files with 696 additions and 73 deletions

View file

@ -13,7 +13,7 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https
| `portsAttributes` | No | Not parsed |
| `otherPortsAttributes` | No | Not parsed |
| `updateRemoteUserUID` | No | Not parsed |
| `containerEnv` | Partial | Parsed in `DevcontainerJson::container_env` but not merged into `DevcontainerConfig::environment` |
| `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` |
| `remoteUser` | Yes | Exposed as `DevcontainerConfig::remote_user` |
@ -33,7 +33,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` | Partial | Parsed in `BuildConfig::args` but not injected into generated Dockerfile |
| `build.args` | Yes | Parsed and exposed in `DevcontainerConfig::build_args` for passing to `docker build --build-arg` |
| `build.target` | No | Not parsed |
| `build.cacheFrom` | No | Not parsed |
| `build.options` | No | Not parsed |
@ -42,7 +42,7 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https
| Property | Status | Notes |
|---|---|---|
| `dockerComposeFile` | Partial | Single file path supported; array of paths not supported |
| `dockerComposeFile` | Yes | Single path and array of paths supported; multiple files are merged (last wins for image/build/user; ports accumulate; environment overrides) |
| `service` | Yes | Required when `dockerComposeFile` is set; used to extract service config |
| `runServices` | No | Not parsed; all services assumed |
| `shutdownAction` | No | Not parsed |
@ -62,7 +62,7 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https
| Property | Status | Notes |
|---|---|---|
| `initializeCommand` | Yes | All three forms supported: string, array, object (parallel). Exposed as `DevcontainerConfig::initialize_commands` |
| `onCreateCommand` | No | Not parsed |
| `onCreateCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::on_create_commands` |
| `updateContentCommand` | No | Not parsed |
| `postCreateCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::post_create_commands` |
| `postStartCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::post_start_commands` |

View file

@ -10,14 +10,17 @@ How to wire the parsed `DevcontainerConfig` into sandbox creation.
pub struct DevcontainerConfig {
pub dockerfile: String, // Generated Dockerfile content
pub build_context: PathBuf, // Directory for docker build
pub initialize_commands: Vec<Command>, // Host-side pre-build commands
pub post_create_commands: Vec<Command>, // Container post-creation setup
pub post_start_commands: Vec<Command>, // Container on-each-start commands
pub environment: HashMap<String, String>, // remoteEnv merged
pub build_args: HashMap<String, String>, // docker build --build-arg flags
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
pub post_start_commands: Vec<Command>, // Container on-each-start commands
pub environment: HashMap<String, String>, // remoteEnv merged
pub container_env: HashMap<String, String>, // containerEnv (also in Dockerfile)
pub remote_user: Option<String>, // Non-root user
pub workspace_folder: String, // Working directory inside container
pub forwarded_ports: Vec<u16>, // Ports to expose
pub compose_file: Option<PathBuf>, // Set when in compose mode
pub compose_files: Vec<PathBuf>, // Compose file paths (empty if not compose mode)
pub compose_service: Option<String>,
}
```
@ -33,10 +36,10 @@ pub struct DevcontainerConfig {
### Environment Variables
`config.environment` contains the merged `remoteEnv` values (with variables already substituted).
`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).
- Pass these as environment variables when creating the sandbox.
- `containerEnv` values (if supported in the future) would be baked into the Dockerfile via `ENV` directives.
- Pass `config.environment` as environment variables when creating the sandbox.
- `containerEnv` values are already in the Dockerfile; `config.container_env` is available for reference.
### Workspace Folder
@ -61,7 +64,7 @@ pub struct DevcontainerConfig {
## Docker Compose DinD Flow
When `config.compose_file` is `Some(path)`, the devcontainer uses Docker Compose mode.
When `config.compose_files` is non-empty, the devcontainer uses Docker Compose mode.
### Strategy
@ -85,7 +88,7 @@ The devcontainer spec defines this execution order:
| Hook | Where | When | `DevcontainerConfig` field |
|---|---|---|---|
| `initializeCommand` | Host | Before build | `initialize_commands` |
| `onCreateCommand` | Container | After first creation | Not captured (not parsed) |
| `onCreateCommand` | Container | After first creation | `on_create_commands` |
| `updateContentCommand` | Container | After create/content update | Not captured (not parsed) |
| `postCreateCommand` | Container | After create/content update | `post_create_commands` |
| `postStartCommand` | Container | On each start | `post_start_commands` |
@ -111,10 +114,11 @@ pub enum Command {
```
1. Run initialize_commands on HOST (before sandbox creation)
2. Build image from config.dockerfile
2. Build image from config.dockerfile (pass config.build_args as --build-arg flags)
3. Create sandbox from image
4. Run post_create_commands in sandbox (as remote_user if set)
5. Run post_start_commands in sandbox (as remote_user if set)
4. Run on_create_commands in sandbox (as remote_user if set)
5. Run post_create_commands in sandbox (as remote_user if set)
6. Run post_start_commands in sandbox (as remote_user if set)
```
## Example Integration Code
@ -131,7 +135,7 @@ async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result<Sandbox> {
}
// 2. Build image and create sandbox
let sandbox = if config.compose_file.is_some() {
let sandbox = if !config.compose_files.is_empty() {
// Compose mode: build from extracted service Dockerfile, then run compose inside
let sandbox = daytona.create_from_dockerfile(
&config.dockerfile,
@ -158,6 +162,9 @@ async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result<Sandbox> {
// 5. Run lifecycle hooks
let user = config.remote_user.as_deref();
for cmd in &config.on_create_commands {
sandbox.exec_command(cmd, user).await?;
}
for cmd in &config.post_create_commands {
sandbox.exec_command(cmd, user).await?;
}
@ -177,10 +184,7 @@ async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result<Sandbox> {
## Edge Cases and Limitations
- **Features require `oras`**: Feature resolution shells out to `oras` CLI for OCI registry pulls. The resolver attempts auto-install if `oras` is not on PATH.
- **`build.args` not injected**: Build arguments are parsed but not passed to `docker build` via `--build-arg` or `ARG` directives.
- **`containerEnv` not merged**: Only `remoteEnv` is included in `DevcontainerConfig::environment`. `containerEnv` is parsed but not forwarded.
- **Single compose file only**: `dockerComposeFile` is treated as a single string path. The spec allows an array of paths for compose file merging.
- **No `onCreateCommand` or `updateContentCommand`**: These lifecycle hooks are not parsed. For first-run setup, `postCreateCommand` serves as the primary hook.
- **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.

View file

@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
/// Extracted configuration from a Docker Compose service.
#[derive(Debug, Clone, Default)]
@ -152,6 +152,60 @@ fn parse_environment(service: &serde_yaml::Value) -> HashMap<String, String> {
HashMap::new()
}
/// Parse multiple Docker Compose files and merge config for the named service.
/// Later files override earlier files for image/build/user; ports accumulate (deduped);
/// environment keys from later files override earlier ones.
pub fn parse_compose_multi(
compose_paths: &[PathBuf],
service_name: &str,
) -> Result<ComposeServiceConfig, String> {
let mut merged = ComposeServiceConfig::default();
let mut found_service = false;
for path in compose_paths {
let contents = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read compose file {}: {e}", path.display()))?;
let doc: serde_yaml::Value = serde_yaml::from_str(&contents)
.map_err(|e| format!("failed to parse YAML {}: {e}", path.display()))?;
let Some(service) = doc.get("services").and_then(|s| s.get(service_name)) else {
continue;
};
found_service = true;
if let Some(image) = service.get("image").and_then(|v| v.as_str()) {
merged.image = Some(image.to_string());
}
if let Some(build) = parse_build(service) {
merged.build = Some(build);
}
if let Some(user) = service.get("user").and_then(|v| v.as_str()) {
merged.user = Some(user.to_string());
}
for port in parse_ports(service) {
if !merged.ports.contains(&port) {
merged.ports.push(port);
}
}
for (k, v) in parse_environment(service) {
merged.environment.insert(k, v);
}
}
if !found_service {
return Err(format!(
"service '{service_name}' not found in any compose file"
));
}
Ok(merged)
}
#[cfg(test)]
mod tests {
use super::*;
@ -301,4 +355,72 @@ services:
let cfg = parse_compose(f.path(), "app").unwrap();
assert_eq!(cfg.user.as_deref(), Some("1000:1000"));
}
#[test]
fn multi_compose_merge() {
let base = write_compose(
r#"
services:
app:
image: node:20
ports:
- "3000:3000"
environment:
- "NODE_ENV=development"
"#,
);
let over = write_compose(
r#"
services:
app:
image: node:22
ports:
- "3000:3000"
- "9229:9229"
environment:
- "DEBUG=true"
"#,
);
let paths = vec![base.path().to_path_buf(), over.path().to_path_buf()];
let cfg = parse_compose_multi(&paths, "app").unwrap();
assert_eq!(cfg.image.as_deref(), Some("node:22"));
assert_eq!(cfg.ports, vec![3000, 9229]);
assert_eq!(cfg.environment.get("NODE_ENV").unwrap(), "development");
assert_eq!(cfg.environment.get("DEBUG").unwrap(), "true");
}
#[test]
fn multi_compose_service_not_found() {
let f = write_compose(
r#"
services:
web:
image: nginx
"#,
);
let paths = vec![f.path().to_path_buf()];
let err = parse_compose_multi(&paths, "missing").unwrap_err();
assert!(err.contains("service 'missing' not found"));
}
#[test]
fn multi_compose_skips_file_without_service() {
let base = write_compose(
r#"
services:
db:
image: postgres:15
"#,
);
let over = write_compose(
r#"
services:
app:
image: node:22
"#,
);
let paths = vec![base.path().to_path_buf(), over.path().to_path_buf()];
let cfg = parse_compose_multi(&paths, "app").unwrap();
assert_eq!(cfg.image.as_deref(), Some("node:22"));
}
}

View file

@ -6,6 +6,7 @@ use crate::features::FeatureLayer;
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 {
@ -18,17 +19,27 @@ pub fn generate(
sections.push(layer.dockerfile_snippet.clone());
}
if let Some(env) = remote_env {
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"));
// 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 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));
@ -53,7 +64,7 @@ mod tests {
#[test]
fn base_image_only() {
let result = generate("FROM ubuntu:22.04", &[], &None, None);
let result = generate("FROM ubuntu:22.04", &[], &None, &None, None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\nFROM ubuntu:22.04\n"
@ -63,7 +74,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);
let result = generate(base, &[], &None, &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"
@ -76,7 +87,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);
let result = generate("FROM ubuntu:22.04", &layers, &None, &None, None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
@ -92,7 +103,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", &[], &Some(env), None);
let result = generate("FROM alpine", &[], &None, &Some(env), None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
@ -105,7 +116,7 @@ mod tests {
#[test]
fn with_remote_user() {
let result = generate("FROM alpine", &[], &None, Some("vscode"));
let result = generate("FROM alpine", &[], &None, &None, Some("vscode"));
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
@ -120,7 +131,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, &Some(env), Some("vscode"));
let result = generate("FROM ubuntu:22.04", &layers, &None, &Some(env), Some("vscode"));
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
@ -134,7 +145,7 @@ mod tests {
#[test]
fn empty_feature_layers_no_extra_blank_lines() {
let result = generate("FROM alpine", &[], &None, Some("dev"));
let result = generate("FROM alpine", &[], &None, &None, Some("dev"));
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
@ -146,7 +157,7 @@ mod tests {
#[test]
fn empty_env_map_treated_as_none() {
let env = HashMap::new();
let result = generate("FROM alpine", &[], &Some(env), None);
let result = generate("FROM alpine", &[], &None, &Some(env), None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\nFROM alpine\n"
@ -162,10 +173,38 @@ mod tests {
\n\
FROM ubuntu:22.04\n\
COPY --from=builder /app/bin /usr/local/bin";
let result = generate(base, &[], &None, None);
let result = generate(base, &[], &None, &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"));
assert!(result.ends_with('\n'));
}
#[test]
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);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
FROM alpine\n\n\
ENV DEBIAN_FRONTEND=noninteractive\n"
);
}
#[test]
fn container_env_and_remote_env_merged() {
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"));
}
}

View file

@ -25,21 +25,27 @@ pub struct DevcontainerConfig {
pub dockerfile: String,
/// Directory for docker build context
pub build_context: PathBuf,
/// Build arguments (docker build --build-arg)
pub build_args: HashMap<String, String>,
/// Run on host before build
pub initialize_commands: Vec<Command>,
/// Run in container after first creation (before updateContentCommand)
pub on_create_commands: Vec<Command>,
/// Run in container after creation
pub post_create_commands: Vec<Command>,
/// Run in container on each start
pub post_start_commands: Vec<Command>,
/// remoteEnv merged
pub environment: HashMap<String, String>,
/// containerEnv — baked into Dockerfile as ENV directives
pub container_env: HashMap<String, String>,
pub remote_user: Option<String>,
/// default: /workspaces/{repo-name}
pub workspace_folder: String,
/// first = default preview port
pub forwarded_ports: Vec<u16>,
/// if dockerComposeFile mode
pub compose_file: Option<PathBuf>,
/// Compose file paths (empty if not in compose mode)
pub compose_files: Vec<PathBuf>,
pub compose_service: Option<String>,
}
@ -113,7 +119,11 @@ impl DevcontainerResolver {
// Handle compose mode
if let Some(compose_ref) = &devcontainer.docker_compose_file {
let compose_path = base_dir.join(variables::substitute(compose_ref, &vars));
let compose_paths: Vec<PathBuf> = compose_ref
.paths()
.iter()
.map(|p| base_dir.join(variables::substitute(p, &vars)))
.collect();
let service_name = devcontainer
.service
.as_ref()
@ -125,8 +135,8 @@ impl DevcontainerResolver {
.clone();
let compose_config =
compose::parse_compose(&compose_path, &service_name).map_err(|e| {
DevcontainerError::Compose(format!("{}: {e}", compose_path.display()))
compose::parse_compose_multi(&compose_paths, &service_name).map_err(|e| {
DevcontainerError::Compose(e)
})?;
let mut environment = HashMap::new();
@ -139,10 +149,14 @@ impl DevcontainerResolver {
}
}
// Use the first compose file's parent as build context base
let compose_base_dir = compose_paths
.first()
.and_then(|p| p.parent())
.unwrap_or(base_dir);
let dockerfile = if let Some(build) = &compose_config.build {
let df_path = compose_path
.parent()
.unwrap_or(base_dir)
let df_path = compose_base_dir
.join(&build.context)
.join(build.dockerfile.as_deref().unwrap_or("Dockerfile"));
std::fs::read_to_string(&df_path).map_err(|source| {
@ -157,11 +171,13 @@ impl DevcontainerResolver {
return Ok(DevcontainerConfig {
dockerfile,
build_context: compose_path
.parent()
.unwrap_or(base_dir)
.to_path_buf(),
build_context: compose_base_dir.to_path_buf(),
build_args: HashMap::new(),
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,
@ -171,19 +187,22 @@ impl DevcontainerResolver {
&vars,
),
environment,
container_env: Self::collect_container_env(&devcontainer.container_env, &vars),
remote_user: devcontainer
.remote_user
.clone()
.or(compose_config.user),
workspace_folder,
forwarded_ports: compose_config.ports,
compose_file: Some(compose_path),
compose_files: compose_paths,
compose_service: Some(service_name),
});
}
// Image or Dockerfile mode
let (base_dockerfile, build_context) = if let Some(build) = &devcontainer.build {
let (base_dockerfile, build_context, build_args) = if let Some(build) =
&devcontainer.build
{
let context_dir = build
.context
.as_ref()
@ -193,20 +212,24 @@ impl DevcontainerResolver {
build.dockerfile.as_deref().unwrap_or("Dockerfile"),
&vars,
));
let content =
std::fs::read_to_string(&df_path).map_err(|source| {
DevcontainerError::ReadFile {
path: df_path,
source,
}
})?;
(content, context_dir)
let content = std::fs::read_to_string(&df_path).map_err(|source| {
DevcontainerError::ReadFile {
path: df_path,
source,
}
})?;
let args: HashMap<String, String> = build
.args
.iter()
.map(|(k, v)| (k.clone(), variables::substitute(v, &vars)))
.collect();
(content, context_dir, args)
} else {
let image = devcontainer
.image
.as_deref()
.unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu");
(format!("FROM {image}"), base_dir.to_path_buf())
(format!("FROM {image}"), base_dir.to_path_buf(), HashMap::new())
};
// Features
@ -220,6 +243,7 @@ impl DevcontainerResolver {
let dockerfile_content = dockerfile::generate(
&base_dockerfile,
&feature_layers,
&devcontainer.container_env,
&devcontainer.remote_env,
devcontainer.remote_user.as_deref(),
);
@ -243,14 +267,17 @@ impl DevcontainerResolver {
Ok(DevcontainerConfig {
dockerfile: dockerfile_content,
build_context,
build_args,
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),
post_start_commands: Self::collect_commands(&devcontainer.post_start_command, &vars),
environment,
container_env: Self::collect_container_env(&devcontainer.container_env, &vars),
remote_user: devcontainer.remote_user.clone(),
workspace_folder,
forwarded_ports,
compose_file: None,
compose_files: Vec::new(),
compose_service: None,
})
}
@ -309,6 +336,19 @@ impl DevcontainerResolver {
original_path
}
fn collect_container_env(
env: &Option<HashMap<String, String>>,
vars: &variables::VariableContext,
) -> HashMap<String, String> {
match env {
None => HashMap::new(),
Some(map) => map
.iter()
.map(|(k, v)| (k.clone(), variables::substitute(v, vars)))
.collect(),
}
}
fn collect_commands(
cmd: &Option<types::LifecycleCommand>,
vars: &variables::VariableContext,

View file

@ -11,8 +11,8 @@ pub struct DevcontainerJson {
/// Dockerfile build config
pub build: Option<BuildConfig>,
/// Docker Compose file path (compose mode)
pub docker_compose_file: Option<String>,
/// Docker Compose file path(s) (compose mode)
pub docker_compose_file: Option<ComposeFileRef>,
/// Service name for compose mode
pub service: Option<String>,
@ -48,7 +48,10 @@ pub struct DevcontainerJson {
/// Run on host before anything else
pub initialize_command: Option<LifecycleCommand>,
/// Run in container after first creation
/// Run in container after first creation (before updateContentCommand)
pub on_create_command: Option<LifecycleCommand>,
/// Run in container after creation
pub post_create_command: Option<LifecycleCommand>,
/// Run in container on every start
@ -72,6 +75,23 @@ pub struct BuildConfig {
pub args: HashMap<String, String>,
}
/// A reference to one or more Docker Compose files.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ComposeFileRef {
Single(String),
Multiple(Vec<String>),
}
impl ComposeFileRef {
pub fn paths(&self) -> Vec<&str> {
match self {
Self::Single(s) => vec![s.as_str()],
Self::Multiple(v) => v.iter().map(String::as_str).collect(),
}
}
}
/// A lifecycle command can be a string, array of strings, or object of named commands.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
@ -188,13 +208,26 @@ mod tests {
}"#;
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
assert_eq!(
config.docker_compose_file.as_deref(),
Some("docker-compose.yml")
config.docker_compose_file.as_ref().unwrap().paths(),
vec!["docker-compose.yml"]
);
assert_eq!(config.service.as_deref(), Some("app"));
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
}
#[test]
fn parse_compose_mode_array() {
let json = r#"{
"dockerComposeFile": ["docker-compose.yml", "docker-compose.override.yml"],
"service": "app"
}"#;
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
assert_eq!(
config.docker_compose_file.as_ref().unwrap().paths(),
vec!["docker-compose.yml", "docker-compose.override.yml"]
);
}
#[test]
fn unknown_fields_ignored() {
let json = r#"{"image": "ubuntu", "unknownField": true, "customizations": {}}"#;

View file

@ -0,0 +1,240 @@
//! End-to-end tests exercising full resolver pipeline with realistic devcontainer configs.
//! These tests verify the 4 critical gaps are wired correctly through the entire stack:
//! 1. onCreateCommand
//! 2. build.args
//! 3. containerEnv
//! 4. dockerComposeFile array
use arc_devcontainer::{Command, DevcontainerResolver};
use std::path::PathBuf;
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
/// Realistic Python project: Dockerfile + build.args + containerEnv + onCreateCommand + remoteEnv
/// Verifies all 4 gaps work together in a single config.
#[tokio::test]
async fn realistic_python_project() {
let config = DevcontainerResolver::resolve(&fixture_path("realistic-python"))
.await
.unwrap();
// Gap 2: build.args exposed for docker build --build-arg
assert_eq!(
config.build_args.get("PYTHON_VERSION").map(String::as_str),
Some("3.12")
);
// Gap 3: containerEnv baked into Dockerfile as ENV directives
assert!(config.dockerfile.contains("ENV PIP_NO_CACHE_DIR=1"));
assert!(config.dockerfile.contains("ENV PYTHONDONTWRITEBYTECODE=1"));
assert_eq!(
config.container_env.get("PIP_NO_CACHE_DIR").map(String::as_str),
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"));
// environment HashMap gets the remoteEnv value
assert_eq!(
config.environment.get("PYTHONUNBUFFERED").map(String::as_str),
Some("yes")
);
// Gap 3: remoteEnv with variable substitution
assert_eq!(
config.environment.get("PYTHONPATH").map(String::as_str),
Some("/workspaces/realistic-python/src")
);
// Gap 1: onCreateCommand parsed and exposed
assert_eq!(config.on_create_commands.len(), 1);
assert!(
matches!(&config.on_create_commands[0], Command::Shell(s) if s == "pip install -r requirements.txt")
);
// Other lifecycle commands still work
assert_eq!(config.post_create_commands.len(), 1);
assert!(
matches!(&config.post_create_commands[0], Command::Shell(s) if s == "python manage.py migrate")
);
assert_eq!(config.post_start_commands.len(), 1);
assert!(
matches!(&config.post_start_commands[0], Command::Shell(s) if s == "python manage.py runserver 0.0.0.0:8000")
);
// Dockerfile content is the actual file (not generated FROM line)
assert!(config.dockerfile.contains("ARG PYTHON_VERSION=3.11"));
assert!(config.dockerfile.contains("apt-get update"));
// Standard fields
assert_eq!(config.remote_user.as_deref(), Some("developer"));
assert_eq!(config.forwarded_ports, vec![8000, 5432]);
assert!(config.compose_files.is_empty());
}
/// Realistic compose project: multi-file compose + containerEnv + onCreateCommand + remoteEnv
/// Verifies gaps 1, 3, 4 work together in compose mode.
#[tokio::test]
async fn realistic_compose_project() {
let config = DevcontainerResolver::resolve(&fixture_path("realistic-compose"))
.await
.unwrap();
// Gap 4: multiple compose files resolved
assert_eq!(config.compose_files.len(), 2);
assert_eq!(config.compose_service.as_deref(), Some("app"));
// 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)
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);
// Gap 4: environment merged from both compose files + remoteEnv
assert_eq!(
config.environment.get("NODE_ENV").map(String::as_str),
Some("development")
);
assert_eq!(
config.environment.get("DEBUG").map(String::as_str),
Some("true")
);
assert_eq!(
config.environment.get("LOG_LEVEL").map(String::as_str),
Some("verbose")
);
// remoteEnv values
assert_eq!(
config.environment.get("DATABASE_URL").map(String::as_str),
Some("postgres://postgres:devpass@db:5432/myapp_dev")
);
assert_eq!(
config.environment.get("REDIS_URL").map(String::as_str),
Some("redis://redis:6379")
);
// Gap 3: containerEnv exposed on config
assert_eq!(
config.container_env.get("TERM").map(String::as_str),
Some("xterm-256color")
);
assert_eq!(
config.container_env.get("EDITOR").map(String::as_str),
Some("vim")
);
// Gap 1: onCreateCommand in compose mode
assert_eq!(config.on_create_commands.len(), 1);
assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == "npm ci"));
// Other lifecycle commands
assert_eq!(config.post_create_commands.len(), 1);
assert!(
matches!(&config.post_create_commands[0], Command::Shell(s) if s == "npm run db:migrate")
);
assert_eq!(config.post_start_commands.len(), 1);
assert!(matches!(&config.post_start_commands[0], Command::Shell(s) if s == "npm run dev"));
// User comes from compose (node) but remoteUser also set to node
assert_eq!(config.remote_user.as_deref(), Some("node"));
assert_eq!(config.workspace_folder, "/workspace");
}
/// All lifecycle commands in different forms: string, array, object, and the new onCreateCommand.
#[tokio::test]
async fn all_lifecycle_command_forms() {
let config = DevcontainerResolver::resolve(&fixture_path("all-lifecycle"))
.await
.unwrap();
// initializeCommand as string
assert_eq!(config.initialize_commands.len(), 1);
assert!(matches!(&config.initialize_commands[0], Command::Shell(s) if s == "echo pre-build"));
// Gap 1: onCreateCommand as array
assert_eq!(config.on_create_commands.len(), 1);
assert!(matches!(&config.on_create_commands[0], Command::Args(args) if args == &["make", "setup"]));
// postCreateCommand as object (parallel)
assert_eq!(config.post_create_commands.len(), 1);
assert!(matches!(&config.post_create_commands[0], Command::Parallel(map) if map.len() == 2));
// postStartCommand as string
assert_eq!(config.post_start_commands.len(), 1);
assert!(matches!(&config.post_start_commands[0], Command::Shell(s) if s == "echo started"));
}
/// Verify containerEnv doesn't pollute the environment HashMap (which is remoteEnv only).
#[tokio::test]
async fn container_env_separate_from_environment() {
let config = DevcontainerResolver::resolve(&fixture_path("realistic-python"))
.await
.unwrap();
// container_env has containerEnv values
assert!(config.container_env.contains_key("PYTHONDONTWRITEBYTECODE"));
assert!(config.container_env.contains_key("PIP_NO_CACHE_DIR"));
// environment only has remoteEnv values (not containerEnv-only keys)
assert!(!config.environment.contains_key("PYTHONDONTWRITEBYTECODE"));
assert!(!config.environment.contains_key("PIP_NO_CACHE_DIR"));
// PYTHONUNBUFFERED is in both - environment gets remoteEnv value
assert_eq!(
config.environment.get("PYTHONUNBUFFERED").map(String::as_str),
Some("yes")
);
}
/// Verify build_args default to empty in non-dockerfile modes.
#[tokio::test]
async fn build_args_empty_in_image_and_compose_modes() {
let image_config = DevcontainerResolver::resolve(&fixture_path("image-only"))
.await
.unwrap();
assert!(image_config.build_args.is_empty());
let compose_config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
.await
.unwrap();
assert!(compose_config.build_args.is_empty());
}
/// Verify compose_files is empty for non-compose modes.
#[tokio::test]
async fn compose_files_empty_in_non_compose_modes() {
let image_config = DevcontainerResolver::resolve(&fixture_path("image-only"))
.await
.unwrap();
assert!(image_config.compose_files.is_empty());
let df_config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode"))
.await
.unwrap();
assert!(df_config.compose_files.is_empty());
}
/// Verify on_create_commands defaults to empty when not specified.
#[tokio::test]
async fn on_create_commands_empty_when_not_specified() {
let config = DevcontainerResolver::resolve(&fixture_path("variables"))
.await
.unwrap();
assert!(config.on_create_commands.is_empty());
}
/// Verify container_env defaults to empty when not specified.
#[tokio::test]
async fn container_env_empty_when_not_specified() {
let config = DevcontainerResolver::resolve(&fixture_path("variables"))
.await
.unwrap();
assert!(config.container_env.is_empty());
}

View file

@ -0,0 +1,10 @@
{
"image": "ubuntu:22.04",
"initializeCommand": "echo pre-build",
"onCreateCommand": ["make", "setup"],
"postCreateCommand": {
"install": "npm install",
"build": "npm run build"
},
"postStartCommand": "echo started"
}

View file

@ -0,0 +1,5 @@
services:
app:
image: node:20
ports:
- "3000:3000"

View file

@ -0,0 +1,5 @@
{
"dockerComposeFile": ["base.yml", "override.yml"],
"service": "app",
"workspaceFolder": "/workspace"
}

View file

@ -0,0 +1,5 @@
services:
app:
image: node:22
environment:
- "OVERRIDE_VAR=true"

View file

@ -2,7 +2,8 @@
// This is a JSONC file with comments
"build": {
"dockerfile": "Dockerfile",
"context": ".."
"context": "..",
"args": {"NODE_VERSION": "20"}
},
"remoteUser": "developer",
"postCreateCommand": "npm install",

View file

@ -5,5 +5,9 @@
"remoteEnv": {
"EDITOR": "code"
},
"containerEnv": {
"DEBIAN_FRONTEND": "noninteractive"
},
"onCreateCommand": "setup.sh",
"postCreateCommand": "echo hello"
}

View file

@ -0,0 +1,17 @@
{
"dockerComposeFile": ["docker-compose.yml", "docker-compose.override.yml"],
"service": "app",
"workspaceFolder": "/workspace",
"remoteUser": "node",
"containerEnv": {
"TERM": "xterm-256color",
"EDITOR": "vim"
},
"remoteEnv": {
"DATABASE_URL": "postgres://postgres:devpass@db:5432/myapp_dev",
"REDIS_URL": "redis://redis:6379"
},
"onCreateCommand": "npm ci",
"postCreateCommand": "npm run db:migrate",
"postStartCommand": "npm run dev"
}

View file

@ -0,0 +1,7 @@
services:
app:
environment:
- "DEBUG=true"
- "LOG_LEVEL=verbose"
ports:
- "4000:4000"

View file

@ -0,0 +1,22 @@
services:
app:
image: node:20-bookworm
ports:
- "3000:3000"
- "9229:9229"
environment:
- "NODE_ENV=development"
user: "node"
volumes:
- ..:/workspace:cached
db:
image: postgres:16
ports:
- "5432:5432"
environment:
POSTGRES_PASSWORD: devpass
POSTGRES_DB: myapp_dev
redis:
image: redis:7-alpine
ports:
- "6379:6379"

View file

@ -0,0 +1,10 @@
ARG PYTHON_VERSION=3.11
FROM python:${PYTHON_VERSION}-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash developer
WORKDIR /workspaces/app

View file

@ -0,0 +1,23 @@
{
// Realistic Python project devcontainer
"build": {
"dockerfile": "Dockerfile",
"args": {
"PYTHON_VERSION": "3.12"
}
},
"containerEnv": {
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONUNBUFFERED": "1",
"PIP_NO_CACHE_DIR": "1"
},
"remoteEnv": {
"PYTHONPATH": "${containerWorkspaceFolder}/src",
"PYTHONUNBUFFERED": "yes"
},
"remoteUser": "developer",
"forwardPorts": [8000, 5432],
"onCreateCommand": "pip install -r requirements.txt",
"postCreateCommand": "python manage.py migrate",
"postStartCommand": "python manage.py runserver 0.0.0.0:8000",
}

View file

@ -18,11 +18,22 @@ async fn resolve_image_only() {
assert_eq!(config.forwarded_ports, vec![3000, 8080]);
assert_eq!(config.environment.get("EDITOR").map(String::as_str), Some("code"));
assert_eq!(config.workspace_folder, "/workspaces/image-only");
assert!(config.compose_file.is_none());
assert!(config.compose_files.is_empty());
assert!(config.compose_service.is_none());
assert_eq!(config.post_create_commands.len(), 1);
assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "echo hello"));
// onCreateCommand
assert_eq!(config.on_create_commands.len(), 1);
assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == "setup.sh"));
// containerEnv baked into Dockerfile
assert!(config.dockerfile.contains("ENV DEBIAN_FRONTEND=noninteractive"));
assert_eq!(
config.container_env.get("DEBIAN_FRONTEND").map(String::as_str),
Some("noninteractive")
);
}
#[tokio::test]
@ -39,6 +50,9 @@ async fn resolve_dockerfile_mode() {
assert_eq!(config.post_create_commands.len(), 1);
assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "npm install"));
// build.args
assert_eq!(config.build_args.get("NODE_VERSION").map(String::as_str), Some("20"));
}
#[tokio::test]
@ -51,7 +65,7 @@ async fn resolve_compose_mode() {
assert!(config.dockerfile.contains("FROM node:20"));
assert_eq!(config.workspace_folder, "/workspace");
assert_eq!(config.remote_user.as_deref(), Some("node"));
assert!(config.compose_file.is_some());
assert_eq!(config.compose_files.len(), 1);
assert_eq!(config.compose_service.as_deref(), Some("app"));
// Ports come from compose + remoteEnv merged
@ -79,6 +93,28 @@ async fn resolve_variables() {
);
}
#[tokio::test]
async fn resolve_compose_multi() {
let config = DevcontainerResolver::resolve(&fixture_path("compose-multi"))
.await
.unwrap();
// Override file wins for image
assert!(config.dockerfile.contains("FROM node:22"));
assert_eq!(config.workspace_folder, "/workspace");
assert_eq!(config.compose_files.len(), 2);
assert_eq!(config.compose_service.as_deref(), Some("app"));
// Port from base.yml
assert_eq!(config.forwarded_ports, vec![3000]);
// Environment from override.yml
assert_eq!(
config.environment.get("OVERRIDE_VAR").map(String::as_str),
Some("true")
);
}
#[tokio::test]
async fn resolve_not_found() {
let result = DevcontainerResolver::resolve(&fixture_path("nonexistent")).await;