diff --git a/crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md b/crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md index 298a9a3c0..63482c366 100644 --- a/crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md +++ b/crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md @@ -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` | diff --git a/crates/arc-devcontainer/INTEGRATION.md b/crates/arc-devcontainer/INTEGRATION.md index b91f2cac2..849dba361 100644 --- a/crates/arc-devcontainer/INTEGRATION.md +++ b/crates/arc-devcontainer/INTEGRATION.md @@ -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, // Host-side pre-build commands - pub post_create_commands: Vec, // Container post-creation setup - pub post_start_commands: Vec, // Container on-each-start commands - pub environment: HashMap, // remoteEnv merged + pub build_args: HashMap, // docker build --build-arg flags + pub initialize_commands: Vec, // Host-side pre-build commands + pub on_create_commands: Vec, // Container after first creation + pub post_create_commands: Vec, // Container post-creation setup + pub post_start_commands: Vec, // Container on-each-start commands + pub environment: HashMap, // remoteEnv merged + pub container_env: HashMap, // containerEnv (also in Dockerfile) pub remote_user: Option, // Non-root user pub workspace_folder: String, // Working directory inside container pub forwarded_ports: Vec, // Ports to expose - pub compose_file: Option, // Set when in compose mode + pub compose_files: Vec, // Compose file paths (empty if not compose mode) pub compose_service: Option, } ``` @@ -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 { } // 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 { // 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 { ## 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. diff --git a/crates/arc-devcontainer/src/compose.rs b/crates/arc-devcontainer/src/compose.rs index 9dc85a640..cbc6d0039 100644 --- a/crates/arc-devcontainer/src/compose.rs +++ b/crates/arc-devcontainer/src/compose.rs @@ -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 { 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 { + 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")); + } } diff --git a/crates/arc-devcontainer/src/dockerfile.rs b/crates/arc-devcontainer/src/dockerfile.rs index c66b49ceb..311df5d03 100644 --- a/crates/arc-devcontainer/src/dockerfile.rs +++ b/crates/arc-devcontainer/src/dockerfile.rs @@ -6,6 +6,7 @@ use crate::features::FeatureLayer; pub fn generate( base_dockerfile: &str, feature_layers: &[FeatureLayer], + container_env: &Option>, remote_env: &Option>, 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 = 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 = 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")); + } } diff --git a/crates/arc-devcontainer/src/lib.rs b/crates/arc-devcontainer/src/lib.rs index 70997abda..27fd5f8d3 100644 --- a/crates/arc-devcontainer/src/lib.rs +++ b/crates/arc-devcontainer/src/lib.rs @@ -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, /// Run on host before build pub initialize_commands: Vec, + /// Run in container after first creation (before updateContentCommand) + pub on_create_commands: Vec, /// Run in container after creation pub post_create_commands: Vec, /// Run in container on each start pub post_start_commands: Vec, /// remoteEnv merged pub environment: HashMap, + /// containerEnv — baked into Dockerfile as ENV directives + pub container_env: HashMap, pub remote_user: Option, /// default: /workspaces/{repo-name} pub workspace_folder: String, /// first = default preview port pub forwarded_ports: Vec, - /// if dockerComposeFile mode - pub compose_file: Option, + /// Compose file paths (empty if not in compose mode) + pub compose_files: Vec, pub compose_service: Option, } @@ -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 = 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 = 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>, + vars: &variables::VariableContext, + ) -> HashMap { + match env { + None => HashMap::new(), + Some(map) => map + .iter() + .map(|(k, v)| (k.clone(), variables::substitute(v, vars))) + .collect(), + } + } + fn collect_commands( cmd: &Option, vars: &variables::VariableContext, diff --git a/crates/arc-devcontainer/src/types.rs b/crates/arc-devcontainer/src/types.rs index 83721a15d..0e7e83157 100644 --- a/crates/arc-devcontainer/src/types.rs +++ b/crates/arc-devcontainer/src/types.rs @@ -11,8 +11,8 @@ pub struct DevcontainerJson { /// Dockerfile build config pub build: Option, - /// Docker Compose file path (compose mode) - pub docker_compose_file: Option, + /// Docker Compose file path(s) (compose mode) + pub docker_compose_file: Option, /// Service name for compose mode pub service: Option, @@ -48,7 +48,10 @@ pub struct DevcontainerJson { /// Run on host before anything else pub initialize_command: Option, - /// Run in container after first creation + /// Run in container after first creation (before updateContentCommand) + pub on_create_command: Option, + + /// Run in container after creation pub post_create_command: Option, /// Run in container on every start @@ -72,6 +75,23 @@ pub struct BuildConfig { pub args: HashMap, } +/// A reference to one or more Docker Compose files. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum ComposeFileRef { + Single(String), + Multiple(Vec), +} + +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": {}}"#; diff --git a/crates/arc-devcontainer/tests/e2e.rs b/crates/arc-devcontainer/tests/e2e.rs new file mode 100644 index 000000000..67741864f --- /dev/null +++ b/crates/arc-devcontainer/tests/e2e.rs @@ -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()); +} diff --git a/crates/arc-devcontainer/tests/fixtures/all-lifecycle/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/all-lifecycle/.devcontainer/devcontainer.json new file mode 100644 index 000000000..38ba7b416 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/all-lifecycle/.devcontainer/devcontainer.json @@ -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" +} diff --git a/crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/base.yml b/crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/base.yml new file mode 100644 index 000000000..d6fd09beb --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/base.yml @@ -0,0 +1,5 @@ +services: + app: + image: node:20 + ports: + - "3000:3000" diff --git a/crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/devcontainer.json new file mode 100644 index 000000000..c25db063d --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +{ + "dockerComposeFile": ["base.yml", "override.yml"], + "service": "app", + "workspaceFolder": "/workspace" +} diff --git a/crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/override.yml b/crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/override.yml new file mode 100644 index 000000000..55023a393 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/override.yml @@ -0,0 +1,5 @@ +services: + app: + image: node:22 + environment: + - "OVERRIDE_VAR=true" diff --git a/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json index 9deb26cf1..0e4fc0533 100644 --- a/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json +++ b/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json @@ -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", diff --git a/crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json index 68608b4a8..b12afca57 100644 --- a/crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json +++ b/crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json @@ -5,5 +5,9 @@ "remoteEnv": { "EDITOR": "code" }, + "containerEnv": { + "DEBIAN_FRONTEND": "noninteractive" + }, + "onCreateCommand": "setup.sh", "postCreateCommand": "echo hello" } diff --git a/crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json new file mode 100644 index 000000000..4d4fce058 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json @@ -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" +} diff --git a/crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.override.yml b/crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.override.yml new file mode 100644 index 000000000..ef2061b1b --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.override.yml @@ -0,0 +1,7 @@ +services: + app: + environment: + - "DEBUG=true" + - "LOG_LEVEL=verbose" + ports: + - "4000:4000" diff --git a/crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.yml b/crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.yml new file mode 100644 index 000000000..7937512cd --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.yml @@ -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" diff --git a/crates/arc-devcontainer/tests/fixtures/realistic-python/.devcontainer/Dockerfile b/crates/arc-devcontainer/tests/fixtures/realistic-python/.devcontainer/Dockerfile new file mode 100644 index 000000000..16f9220a8 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/realistic-python/.devcontainer/Dockerfile @@ -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 diff --git a/crates/arc-devcontainer/tests/fixtures/realistic-python/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/realistic-python/.devcontainer/devcontainer.json new file mode 100644 index 000000000..6fd7a715b --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/realistic-python/.devcontainer/devcontainer.json @@ -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", +} diff --git a/crates/arc-devcontainer/tests/integration.rs b/crates/arc-devcontainer/tests/integration.rs index c626fa54e..3c37cb0ea 100644 --- a/crates/arc-devcontainer/tests/integration.rs +++ b/crates/arc-devcontainer/tests/integration.rs @@ -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;