From 5e337b4cb3f35303ad9a42e5b548c87da1f39763 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 2 Mar 2026 22:38:12 -0500 Subject: [PATCH] Add arc-devcontainer crate for parsing and resolving devcontainer.json Standalone crate that reads devcontainer.json (with JSONC support), fetches OCI Features via oras, and produces a resolved config containing a generated Dockerfile, lifecycle hooks, environment variables, and forwarded ports. Supports image, Dockerfile, and Docker Compose modes with devcontainer variable substitution. No coupling to arc-workflows or DaytonaSandbox. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 13 + crates/arc-devcontainer/Cargo.toml | 21 + .../DEVCONTAINER-COMPATIBILITY.md | 114 ++++ crates/arc-devcontainer/INTEGRATION.md | 186 +++++ crates/arc-devcontainer/src/compose.rs | 304 +++++++++ crates/arc-devcontainer/src/dockerfile.rs | 171 +++++ crates/arc-devcontainer/src/features.rs | 641 ++++++++++++++++++ crates/arc-devcontainer/src/jsonc.rs | 280 ++++++++ crates/arc-devcontainer/src/lib.rs | 337 +++++++++ crates/arc-devcontainer/src/types.rs | 225 ++++++ crates/arc-devcontainer/src/variables.rs | 223 ++++++ .../.devcontainer/devcontainer.json | 10 + .../.devcontainer/docker-compose.yml | 13 + .../dockerfile-mode/.devcontainer/Dockerfile | 3 + .../.devcontainer/devcontainer.json | 10 + .../.devcontainer/devcontainer.json | 9 + .../variables/.devcontainer/devcontainer.json | 9 + .../fixtures/with-features/devcontainer.json | 9 + crates/arc-devcontainer/tests/integration.rs | 102 +++ 19 files changed, 2680 insertions(+) create mode 100644 crates/arc-devcontainer/Cargo.toml create mode 100644 crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md create mode 100644 crates/arc-devcontainer/INTEGRATION.md create mode 100644 crates/arc-devcontainer/src/compose.rs create mode 100644 crates/arc-devcontainer/src/dockerfile.rs create mode 100644 crates/arc-devcontainer/src/features.rs create mode 100644 crates/arc-devcontainer/src/jsonc.rs create mode 100644 crates/arc-devcontainer/src/lib.rs create mode 100644 crates/arc-devcontainer/src/types.rs create mode 100644 crates/arc-devcontainer/src/variables.rs create mode 100644 crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json create mode 100644 crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/docker-compose.yml create mode 100644 crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/Dockerfile create mode 100644 crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json create mode 100644 crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json create mode 100644 crates/arc-devcontainer/tests/fixtures/variables/.devcontainer/devcontainer.json create mode 100644 crates/arc-devcontainer/tests/fixtures/with-features/devcontainer.json create mode 100644 crates/arc-devcontainer/tests/integration.rs diff --git a/Cargo.lock b/Cargo.lock index 30ae296a1..496ffca00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "arc-devcontainer" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "arc-git-storage" version = "0.1.0" diff --git a/crates/arc-devcontainer/Cargo.toml b/crates/arc-devcontainer/Cargo.toml new file mode 100644 index 000000000..479515e3f --- /dev/null +++ b/crates/arc-devcontainer/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "arc-devcontainer" +edition.workspace = true +version.workspace = true +license.workspace = true +description = "Parse and resolve devcontainer.json into Dockerfiles and lifecycle hooks" + +[lib] +doctest = false + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = "0.9" +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util", "macros"] } +tempfile = "3" diff --git a/crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md b/crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md new file mode 100644 index 000000000..298a9a3c0 --- /dev/null +++ b/crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md @@ -0,0 +1,114 @@ +# Devcontainer Spec Compatibility Matrix + +Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https://containers.dev/implementors/json_reference/). + +**Legend**: Yes = fully supported, Partial = parsed but incomplete, No = not supported, Planned = intended for future + +## General + +| 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 | +| `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` | +| `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` | +| `userEnvProbe` | No | Not parsed | +| `overrideCommand` | Partial | Parsed in `DevcontainerJson::override_command` but not acted on | +| `shutdownAction` | No | Not parsed | + +## Image + +| Property | Status | Notes | +|---|---|---| +| `image` | Yes | Used as `FROM` line when no Dockerfile is specified; defaults to `mcr.microsoft.com/devcontainers/base:ubuntu` | + +## Build (Dockerfile) + +| Property | Status | Notes | +|---|---|---| +| `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.target` | No | Not parsed | +| `build.cacheFrom` | No | Not parsed | +| `build.options` | No | Not parsed | + +## Compose + +| Property | Status | Notes | +|---|---|---| +| `dockerComposeFile` | Partial | Single file path supported; array of paths not supported | +| `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 | +| `workspaceFolder` | Yes | Defaults to `/workspaces/{repo-name}` | +| `workspaceMount` | Partial | Parsed in `DevcontainerJson::workspace_mount` but not used | + +## Features + +| Property | Status | Notes | +|---|---|---| +| `features` | Yes | Fetched via `oras` CLI, topologically sorted by `installsAfter`, Dockerfile layers generated with options as env vars | +| `overrideFeatureInstallOrder` | No | Not parsed | + +## Lifecycle + +| Property | Status | Notes | +|---|---|---| +| `initializeCommand` | Yes | All three forms supported: string, array, object (parallel). Exposed as `DevcontainerConfig::initialize_commands` | +| `onCreateCommand` | No | Not parsed | +| `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` | +| `postAttachCommand` | No | Not parsed | +| `waitFor` | No | Not parsed | + +## Host + +| Property | Status | Notes | +|---|---|---| +| `hostRequirements` | No | Not parsed | +| `init` | No | Not parsed | +| `privileged` | No | Not parsed | +| `capAdd` | No | Not parsed | +| `securityOpt` | No | Not parsed | +| `mounts` | No | Not parsed | +| `gpuRequest` | No | Not parsed | + +## Customizations + +| Property | Status | Notes | +|---|---|---| +| `customizations` | No | Unknown fields are silently ignored by serde, so `customizations` is accepted but not processed | + +## Variables + +| Variable | Status | Notes | +|---|---|---| +| `${localWorkspaceFolder}` | Yes | Substituted via `VariableContext` | +| `${localWorkspaceFolderBasename}` | Yes | Substituted via `VariableContext` | +| `${containerWorkspaceFolder}` | Yes | Substituted via `VariableContext` | +| `${containerWorkspaceFolderBasename}` | Yes | Derived from `containerWorkspaceFolder` by splitting on `/` | +| `${localEnv:VAR}` | Yes | Reads from host environment; supports `:default` syntax | +| `${containerEnv:VAR}` | No | Not implemented (requires running container) | +| `${devcontainerId}` | No | Not implemented | + +## JSONC Support + +The parser supports JSONC (JSON with Comments): +- Line comments (`//`) +- Block comments (`/* */`) +- Trailing commas before `}` and `]` + +## File Discovery + +Searched in order: +1. `/.devcontainer/devcontainer.json` +2. `/.devcontainer.json` +3. Direct path if it ends in `devcontainer.json` diff --git a/crates/arc-devcontainer/INTEGRATION.md b/crates/arc-devcontainer/INTEGRATION.md new file mode 100644 index 000000000..b91f2cac2 --- /dev/null +++ b/crates/arc-devcontainer/INTEGRATION.md @@ -0,0 +1,186 @@ +# Integrating DevcontainerConfig with DaytonaSandbox + +How to wire the parsed `DevcontainerConfig` into sandbox creation. + +## Overview + +`DevcontainerResolver::resolve(repo_path)` reads a repository's devcontainer.json and produces a `DevcontainerConfig` containing everything needed to build and configure a sandbox: + +```rust +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 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_service: Option, +} +``` + +## Mapping Devcontainer to Daytona + +### Dockerfile and Image Build + +`config.dockerfile` contains the full Dockerfile content (not a path). For image-only configs, this is a single `FROM` line. For Dockerfile configs, it is the file content with feature layers appended. + +- Build a Docker image from `config.dockerfile` using `config.build_context` as the build context directory. +- Use this image as the Daytona sandbox snapshot/base image. + +### Environment Variables + +`config.environment` contains the merged `remoteEnv` values (with variables already substituted). + +- 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. + +### Workspace Folder + +`config.workspace_folder` defaults to `/workspaces/{repo-name}`. + +- Set this as the sandbox working directory. +- Mount or clone the repository into this path. + +### Remote User + +`config.remote_user` specifies the non-root user for running dev tools. + +- Use this as the sandbox exec user when running lifecycle commands and user sessions. +- Falls back to root if not set. + +### Forwarded Ports + +`config.forwarded_ports` lists ports to expose (first port = default preview). + +- Use the first port as the default preview URL for the sandbox. +- Forward all listed ports from the sandbox to the user. + +## Docker Compose DinD Flow + +When `config.compose_file` is `Some(path)`, the devcontainer uses Docker Compose mode. + +### Strategy + +Run Docker-in-Docker (DinD) inside the Daytona sandbox: + +1. Create a sandbox using the extracted Dockerfile from the compose service. +2. Install Docker daemon inside the sandbox (or use a DinD-capable base image). +3. Copy the compose file and related context into the sandbox. +4. Run `docker compose up` inside the sandbox to start all services. +5. The compose service ports become available on localhost inside the sandbox. +6. Forward those ports from the sandbox to the user. + +### Port Forwarding + +Ports come from the compose service's `ports` configuration (parsed by `compose::parse_compose`). The compose parser extracts container-side ports from formats like `"8080:80"`, `"3000"`, and `5432`. + +## Lifecycle Hook Execution Order + +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) | +| `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` | +| `postAttachCommand` | Container | On each attach | Not captured (not parsed) | + +### Command Types + +Each command is represented as a `Command` enum: + +```rust +pub enum Command { + Shell(String), // "npm install" + Args(Vec), // ["npm", "install"] + Parallel(HashMap), // {"install": "npm install", "build": "npm run build"} +} +``` + +- `Shell` -- execute via `sh -c ""` +- `Args` -- execute directly as argv +- `Parallel` -- execute all values concurrently, wait for all to complete + +### Execution in Sandbox + +``` +1. Run initialize_commands on HOST (before sandbox creation) +2. Build image from config.dockerfile +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) +``` + +## Example Integration Code + +```rust +use arc_devcontainer::{DevcontainerResolver, DevcontainerConfig, Command}; + +async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result { + let config = DevcontainerResolver::resolve(repo_path).await?; + + // 1. Run host-side init commands + for cmd in &config.initialize_commands { + run_host_command(cmd).await?; + } + + // 2. Build image and create sandbox + let sandbox = if config.compose_file.is_some() { + // Compose mode: build from extracted service Dockerfile, then run compose inside + let sandbox = daytona.create_from_dockerfile( + &config.dockerfile, + &config.build_context, + ).await?; + setup_dind(&sandbox).await?; + sandbox.exec("docker compose up -d").await?; + sandbox + } else { + // Image/Dockerfile mode: build directly + daytona.create_from_dockerfile( + &config.dockerfile, + &config.build_context, + ).await? + }; + + // 3. Configure environment + for (key, value) in &config.environment { + sandbox.set_env(key, value).await?; + } + + // 4. Set working directory + sandbox.set_workdir(&config.workspace_folder).await?; + + // 5. Run lifecycle hooks + let user = config.remote_user.as_deref(); + for cmd in &config.post_create_commands { + sandbox.exec_command(cmd, user).await?; + } + for cmd in &config.post_start_commands { + sandbox.exec_command(cmd, user).await?; + } + + // 6. Set up port forwarding + if let Some(port) = config.forwarded_ports.first() { + sandbox.set_preview_port(*port).await?; + } + + Ok(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 `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 new file mode 100644 index 000000000..9dc85a640 --- /dev/null +++ b/crates/arc-devcontainer/src/compose.rs @@ -0,0 +1,304 @@ +use std::collections::HashMap; +use std::path::Path; + +/// Extracted configuration from a Docker Compose service. +#[derive(Debug, Clone, Default)] +pub struct ComposeServiceConfig { + pub image: Option, + pub build: Option, + pub ports: Vec, + pub environment: HashMap, + pub user: Option, +} + +/// Build configuration from a Docker Compose service. +#[derive(Debug, Clone)] +pub struct ComposeBuild { + pub context: String, + pub dockerfile: Option, +} + +/// Parse a Docker Compose file and extract config for the named service. +pub fn parse_compose( + compose_path: &Path, + service_name: &str, +) -> Result { + let contents = std::fs::read_to_string(compose_path) + .map_err(|e| format!("failed to read compose file: {e}"))?; + + let doc: serde_yaml::Value = + serde_yaml::from_str(&contents).map_err(|e| format!("failed to parse YAML: {e}"))?; + + let service = doc + .get("services") + .and_then(|s| s.get(service_name)) + .ok_or_else(|| format!("service '{service_name}' not found in compose file"))?; + + let image = service + .get("image") + .and_then(|v| v.as_str()) + .map(String::from); + + let build = parse_build(service); + let ports = parse_ports(service); + let environment = parse_environment(service); + + let user = service + .get("user") + .and_then(|v| v.as_str()) + .map(String::from); + + Ok(ComposeServiceConfig { + image, + build, + ports, + environment, + user, + }) +} + +fn parse_build(service: &serde_yaml::Value) -> Option { + let build_val = service.get("build")?; + + if let Some(context) = build_val.as_str() { + return Some(ComposeBuild { + context: context.to_string(), + dockerfile: None, + }); + } + + if build_val.is_mapping() { + let context = build_val + .get("context") + .and_then(|v| v.as_str()) + .unwrap_or(".") + .to_string(); + let dockerfile = build_val + .get("dockerfile") + .and_then(|v| v.as_str()) + .map(String::from); + return Some(ComposeBuild { + context, + dockerfile, + }); + } + + None +} + +fn parse_ports(service: &serde_yaml::Value) -> Vec { + let Some(ports_val) = service.get("ports") else { + return Vec::new(); + }; + let Some(ports_seq) = ports_val.as_sequence() else { + return Vec::new(); + }; + + ports_seq + .iter() + .filter_map(|entry| { + if let Some(n) = entry.as_u64() { + return u16::try_from(n).ok(); + } + if let Some(s) = entry.as_str() { + // Formats: "8080:80", "3000", "8080:80/tcp" + let s = s.split('/').next().unwrap_or(s); // strip protocol + return if let Some((_host, container)) = s.split_once(':') { + container.parse::().ok() + } else { + s.parse::().ok() + }; + } + None + }) + .collect() +} + +fn parse_environment(service: &serde_yaml::Value) -> HashMap { + let Some(env_val) = service.get("environment") else { + return HashMap::new(); + }; + + // Array form: ["KEY=VALUE", ...] + if let Some(seq) = env_val.as_sequence() { + return seq + .iter() + .filter_map(|v| { + let s = v.as_str()?; + let (key, value) = s.split_once('=')?; + Some((key.to_string(), value.to_string())) + }) + .collect(); + } + + // Object form: { KEY: VALUE, ... } + if let Some(mapping) = env_val.as_mapping() { + return mapping + .iter() + .filter_map(|(k, v)| { + let key = k.as_str()?.to_string(); + let value = match v { + serde_yaml::Value::String(s) => s.clone(), + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::Bool(b) => b.to_string(), + serde_yaml::Value::Null => String::new(), + _ => return None, + }; + Some((key, value)) + }) + .collect(); + } + + HashMap::new() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write_compose(content: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f + } + + #[test] + fn service_with_image_only() { + let f = write_compose( + r#" +services: + web: + image: nginx:latest +"#, + ); + let cfg = parse_compose(f.path(), "web").unwrap(); + assert_eq!(cfg.image.as_deref(), Some("nginx:latest")); + assert!(cfg.build.is_none()); + assert!(cfg.ports.is_empty()); + assert!(cfg.environment.is_empty()); + assert!(cfg.user.is_none()); + } + + #[test] + fn service_with_build_string() { + let f = write_compose( + r#" +services: + app: + build: ./src +"#, + ); + let cfg = parse_compose(f.path(), "app").unwrap(); + let build = cfg.build.unwrap(); + assert_eq!(build.context, "./src"); + assert!(build.dockerfile.is_none()); + } + + #[test] + fn service_with_build_object() { + let f = write_compose( + r#" +services: + app: + build: + context: ./app + dockerfile: Dockerfile.dev +"#, + ); + let cfg = parse_compose(f.path(), "app").unwrap(); + let build = cfg.build.unwrap(); + assert_eq!(build.context, "./app"); + assert_eq!(build.dockerfile.as_deref(), Some("Dockerfile.dev")); + } + + #[test] + fn ports_various_formats() { + let f = write_compose( + r#" +services: + web: + image: nginx + ports: + - "8080:80" + - "3000" + - 5432 + - "9090:9090/tcp" +"#, + ); + let cfg = parse_compose(f.path(), "web").unwrap(); + assert_eq!(cfg.ports, vec![80, 3000, 5432, 9090]); + } + + #[test] + fn environment_as_array() { + let f = write_compose( + r#" +services: + app: + image: myapp + environment: + - "DATABASE_URL=postgres://localhost/db" + - "DEBUG=true" +"#, + ); + let cfg = parse_compose(f.path(), "app").unwrap(); + assert_eq!(cfg.environment.len(), 2); + assert_eq!( + cfg.environment.get("DATABASE_URL").unwrap(), + "postgres://localhost/db" + ); + assert_eq!(cfg.environment.get("DEBUG").unwrap(), "true"); + } + + #[test] + fn environment_as_object() { + let f = write_compose( + r#" +services: + app: + image: myapp + environment: + RAILS_ENV: production + PORT: 3000 +"#, + ); + let cfg = parse_compose(f.path(), "app").unwrap(); + assert_eq!(cfg.environment.len(), 2); + assert_eq!(cfg.environment.get("RAILS_ENV").unwrap(), "production"); + assert_eq!(cfg.environment.get("PORT").unwrap(), "3000"); + } + + #[test] + fn service_not_found() { + let f = write_compose( + r#" +services: + web: + image: nginx +"#, + ); + let err = parse_compose(f.path(), "missing").unwrap_err(); + assert!(err.contains("service 'missing' not found")); + } + + #[test] + fn file_not_found() { + let err = parse_compose(Path::new("/nonexistent/docker-compose.yml"), "web").unwrap_err(); + assert!(err.contains("failed to read compose file")); + } + + #[test] + fn service_with_user() { + let f = write_compose( + r#" +services: + app: + image: myapp + user: "1000:1000" +"#, + ); + let cfg = parse_compose(f.path(), "app").unwrap(); + assert_eq!(cfg.user.as_deref(), Some("1000:1000")); + } +} diff --git a/crates/arc-devcontainer/src/dockerfile.rs b/crates/arc-devcontainer/src/dockerfile.rs new file mode 100644 index 000000000..c66b49ceb --- /dev/null +++ b/crates/arc-devcontainer/src/dockerfile.rs @@ -0,0 +1,171 @@ +use std::collections::HashMap; + +use crate::features::FeatureLayer; + +/// Generate a combined Dockerfile from base + features + env + user. +pub fn generate( + base_dockerfile: &str, + feature_layers: &[FeatureLayer], + remote_env: &Option>, + remote_user: Option<&str>, +) -> String { + let mut sections: Vec = Vec::new(); + + sections.push("# Generated by arc-devcontainer".to_string()); + sections.push(base_dockerfile.to_string()); + + for layer in feature_layers { + 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")); + } + } + + if let Some(user) = remote_user { + sections.push(format!("USER {}", user)); + } + + let mut result = sections.join("\n\n"); + result.push('\n'); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_layer(id: &str, dir_name: &str, snippet: &str) -> FeatureLayer { + FeatureLayer { + id: id.to_string(), + dir_name: dir_name.to_string(), + dockerfile_snippet: snippet.to_string(), + } + } + + #[test] + fn base_image_only() { + let result = generate("FROM ubuntu:22.04", &[], &None, None); + assert_eq!( + result, + "# Generated by arc-devcontainer\n\nFROM ubuntu:22.04\n" + ); + } + + #[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); + assert_eq!( + result, + "# Generated by arc-devcontainer\n\nFROM ubuntu:22.04\nRUN apt-get update\nRUN apt-get install -y curl\n" + ); + } + + #[test] + fn with_feature_layers() { + let layers = vec![ + 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); + assert_eq!( + result, + "# Generated by arc-devcontainer\n\n\ + FROM ubuntu:22.04\n\n\ + RUN install-node.sh\n\n\ + RUN install-python.sh\n" + ); + } + + #[test] + fn with_env_sorted() { + let mut env = HashMap::new(); + 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); + assert_eq!( + result, + "# Generated by arc-devcontainer\n\n\ + FROM alpine\n\n\ + ENV APPLE=red\n\ + ENV MANGO=yellow\n\ + ENV ZEBRA=stripes\n" + ); + } + + #[test] + fn with_remote_user() { + let result = generate("FROM alpine", &[], &None, Some("vscode")); + assert_eq!( + result, + "# Generated by arc-devcontainer\n\n\ + FROM alpine\n\n\ + USER vscode\n" + ); + } + + #[test] + fn all_combined() { + let layers = vec![make_layer("node", "node-1", "RUN install-node.sh")]; + 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")); + assert_eq!( + result, + "# Generated by arc-devcontainer\n\n\ + FROM ubuntu:22.04\n\n\ + RUN install-node.sh\n\n\ + ENV HOME=/home/vscode\n\ + ENV PATH=/usr/local/bin\n\n\ + USER vscode\n" + ); + } + + #[test] + fn empty_feature_layers_no_extra_blank_lines() { + let result = generate("FROM alpine", &[], &None, Some("dev")); + assert_eq!( + result, + "# Generated by arc-devcontainer\n\n\ + FROM alpine\n\n\ + USER dev\n" + ); + } + + #[test] + fn empty_env_map_treated_as_none() { + let env = HashMap::new(); + let result = generate("FROM alpine", &[], &Some(env), None); + assert_eq!( + result, + "# Generated by arc-devcontainer\n\nFROM alpine\n" + ); + } + + #[test] + fn multiline_base_dockerfile() { + let base = "FROM ubuntu:22.04 AS builder\n\ + RUN apt-get update && apt-get install -y build-essential\n\ + COPY . /app\n\ + RUN make\n\ + \n\ + FROM ubuntu:22.04\n\ + COPY --from=builder /app/bin /usr/local/bin"; + 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")); + assert!(result.ends_with('\n')); + } +} diff --git a/crates/arc-devcontainer/src/features.rs b/crates/arc-devcontainer/src/features.rs new file mode 100644 index 000000000..ccc2cab62 --- /dev/null +++ b/crates/arc-devcontainer/src/features.rs @@ -0,0 +1,641 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::Path; + +use tracing::info; + +use crate::types::FeatureMetadata; +use crate::DevcontainerError; + +/// A resolved feature layer ready to be inserted into a Dockerfile. +#[derive(Debug, Clone)] +pub struct FeatureLayer { + /// Feature identifier (e.g. "ghcr.io/devcontainers/features/node:1") + pub id: String, + /// Directory name for COPY + pub dir_name: String, + /// Dockerfile snippet for this feature + pub dockerfile_snippet: String, +} + +/// Extract the directory name from a feature ID. +/// e.g. "ghcr.io/devcontainers/features/node:1" -> "node" +fn dir_name_from_id(feature_id: &str) -> String { + let without_tag = feature_id.split(':').next().unwrap_or(feature_id); + without_tag + .rsplit('/') + .next() + .unwrap_or(without_tag) + .to_string() +} + +/// Ensure `oras` CLI is available, installing it if necessary. +async fn ensure_oras() -> crate::Result<()> { + let check = tokio::process::Command::new("which") + .arg("oras") + .output() + .await + .map_err(|e| DevcontainerError::OrasInstall(format!("failed to check for oras: {e}")))?; + + if check.status.success() { + return Ok(()); + } + + info!("oras not found, attempting to install"); + + if cfg!(target_os = "macos") { + let status = tokio::process::Command::new("brew") + .args(["install", "oras"]) + .status() + .await + .map_err(|e| { + DevcontainerError::OrasInstall(format!("failed to run brew install oras: {e}")) + })?; + + if !status.success() { + return Err(DevcontainerError::OrasInstall( + "brew install oras failed".to_string(), + )); + } + } else { + // Linux: download from GitHub releases to ~/.local/bin/ + let home = std::env::var("HOME") + .map_err(|_| DevcontainerError::OrasInstall("HOME not set".to_string()))?; + let bin_dir = format!("{home}/.local/bin"); + + tokio::fs::create_dir_all(&bin_dir).await.map_err(|e| { + DevcontainerError::OrasInstall(format!("failed to create {bin_dir}: {e}")) + })?; + + let version = "1.2.0"; + let arch = if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "amd64" + }; + let url = format!( + "https://github.com/oras-project/oras/releases/download/v{version}/oras_{version}_linux_{arch}.tar.gz" + ); + + let status = tokio::process::Command::new("sh") + .args([ + "-c", + &format!("curl -fsSL '{url}' | tar xzf - -C '{bin_dir}' oras"), + ]) + .status() + .await + .map_err(|e| { + DevcontainerError::OrasInstall(format!("failed to download oras: {e}")) + })?; + + if !status.success() { + return Err(DevcontainerError::OrasInstall( + "downloading oras from GitHub releases failed".to_string(), + )); + } + } + + Ok(()) +} + +/// Fetch a single feature using `oras pull` and extract its contents. +/// Returns the parsed feature metadata. +async fn fetch_feature( + feature_id: &str, + output_dir: &Path, +) -> crate::Result { + let dir_name = dir_name_from_id(feature_id); + let feature_dir = output_dir.join(&dir_name); + tokio::fs::create_dir_all(&feature_dir) + .await + .map_err(|e| { + DevcontainerError::Feature(format!( + "failed to create dir {}: {e}", + feature_dir.display() + )) + })?; + + info!(feature_id, "pulling feature with oras"); + + let output = tokio::process::Command::new("oras") + .args(["pull", feature_id, "-o"]) + .arg(&feature_dir) + .output() + .await + .map_err(|e| DevcontainerError::OrasCommand(format!("failed to run oras pull: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(DevcontainerError::OrasCommand(format!( + "oras pull {feature_id} failed: {stderr}" + ))); + } + + // Extract any tgz files + let tgz_path = feature_dir.join("devcontainer-feature.tgz"); + if tgz_path.exists() { + let status = tokio::process::Command::new("tar") + .args(["xzf", "devcontainer-feature.tgz"]) + .current_dir(&feature_dir) + .status() + .await + .map_err(|e| { + DevcontainerError::Feature(format!("failed to extract tgz: {e}")) + })?; + + if !status.success() { + return Err(DevcontainerError::Feature(format!( + "tar extraction failed for {feature_id}" + ))); + } + } + + // Read metadata + let metadata_path = feature_dir.join("devcontainer-feature.json"); + let metadata_str = tokio::fs::read_to_string(&metadata_path) + .await + .map_err(|e| { + DevcontainerError::Feature(format!( + "failed to read {}: {e}", + metadata_path.display() + )) + })?; + + let metadata: FeatureMetadata = serde_json::from_str(&metadata_str).map_err(|e| { + DevcontainerError::Feature(format!( + "failed to parse {}: {e}", + metadata_path.display() + )) + })?; + + Ok(metadata) +} + +/// Topological sort of features based on `installsAfter` dependencies. +/// Uses Kahn's algorithm. Features without ordering constraints maintain input order. +fn topo_sort( + feature_ids: &[String], + metadata_map: &HashMap, +) -> Vec { + if feature_ids.is_empty() { + return Vec::new(); + } + + let id_set: HashSet<&str> = feature_ids.iter().map(|s| s.as_str()).collect(); + + // Build adjacency list and in-degree count. + // An edge from A -> B means "A must be installed before B". + // If B has installsAfter containing a reference matching A, then A -> B. + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + let mut edges: HashMap<&str, Vec<&str>> = HashMap::new(); + + for id in feature_ids { + in_degree.entry(id.as_str()).or_insert(0); + edges.entry(id.as_str()).or_default(); + } + + for id in feature_ids { + if let Some(meta) = metadata_map.get(id) { + for dep in &meta.installs_after { + // Find matching feature in our set + // installsAfter may use short IDs or full IDs, match by dir name or full ID + let dep_dir = dir_name_from_id(dep); + for candidate in feature_ids { + if candidate == dep + || dir_name_from_id(candidate) == dep_dir + { + if id_set.contains(candidate.as_str()) { + // candidate -> id (candidate must come before id) + edges.entry(candidate.as_str()).or_default().push(id.as_str()); + *in_degree.entry(id.as_str()).or_insert(0) += 1; + } + } + } + } + } + } + + // Kahn's algorithm preserving input order for ties + let mut queue: VecDeque<&str> = VecDeque::new(); + for id in feature_ids { + if in_degree.get(id.as_str()).copied().unwrap_or(0) == 0 { + queue.push_back(id.as_str()); + } + } + + let mut sorted: Vec = Vec::new(); + while let Some(node) = queue.pop_front() { + sorted.push(node.to_string()); + if let Some(neighbors) = edges.get(node) { + for neighbor in neighbors { + if let Some(deg) = in_degree.get_mut(neighbor) { + *deg -= 1; + if *deg == 0 { + queue.push_back(neighbor); + } + } + } + } + } + + // If there are cycles, append remaining features in input order + if sorted.len() < feature_ids.len() { + for id in feature_ids { + if !sorted.contains(id) { + sorted.push(id.clone()); + } + } + } + + sorted +} + +/// Generate a Dockerfile snippet for a single feature layer. +fn generate_layer( + feature_id: &str, + dir_name: &str, + options: &serde_json::Value, + metadata: &FeatureMetadata, +) -> String { + let mut env_lines = Vec::new(); + + // Collect all option names from metadata to set defaults + let user_options: HashMap = match options.as_object() { + Some(obj) => obj + .iter() + .map(|(k, v)| { + let val = match v { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + (k.clone(), val) + }) + .collect(), + None => HashMap::new(), + }; + + // Merge metadata defaults with user-provided options + let mut merged_options: Vec<(String, String)> = Vec::new(); + for (opt_name, opt_def) in &metadata.options { + let value = if let Some(user_val) = user_options.get(opt_name) { + user_val.clone() + } else if let Some(default_val) = &opt_def.default { + match default_val { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + other => other.to_string(), + } + } else { + continue; + }; + merged_options.push((opt_name.clone(), value)); + } + + // Also add any user options not in metadata + for (key, val) in &user_options { + if !metadata.options.contains_key(key) { + merged_options.push((key.clone(), val.clone())); + } + } + + // Sort for deterministic output + merged_options.sort_by(|a, b| a.0.cmp(&b.0)); + + for (name, value) in &merged_options { + let env_name = name.to_uppercase(); + env_lines.push(format!(" export {env_name}=\"{value}\" && \\")); + } + + let mut snippet = format!("# Feature: {feature_id}\n"); + snippet.push_str(&format!( + "COPY {dir_name}/ /tmp/devcontainer-features/{dir_name}/\n" + )); + snippet.push_str(&format!( + "RUN cd /tmp/devcontainer-features/{dir_name} && \\\n" + )); + for line in &env_lines { + snippet.push_str(line); + snippet.push('\n'); + } + snippet.push_str(" chmod +x install.sh && \\\n"); + snippet.push_str(" ./install.sh"); + + snippet +} + +/// Fetch, order, and resolve features into Dockerfile layers. +pub async fn resolve_features( + features: &HashMap, + _build_context: &Path, +) -> crate::Result> { + if features.is_empty() { + return Ok(Vec::new()); + } + + ensure_oras().await?; + + let tmp_dir = std::env::temp_dir().join("devcontainer-features"); + tokio::fs::create_dir_all(&tmp_dir).await.map_err(|e| { + DevcontainerError::Feature(format!("failed to create temp dir: {e}")) + })?; + + // Collect feature IDs in a stable order + let feature_ids: Vec = features.keys().cloned().collect(); + + // Fetch all features and collect metadata + let mut metadata_map: HashMap = HashMap::new(); + for feature_id in &feature_ids { + let metadata = fetch_feature(feature_id, &tmp_dir).await?; + metadata_map.insert(feature_id.clone(), metadata); + } + + // Topologically sort features + let sorted_ids = topo_sort(&feature_ids, &metadata_map); + + // Generate layers + let mut layers = Vec::new(); + for id in &sorted_ids { + let dir_name = dir_name_from_id(id); + let options = features.get(id).cloned().unwrap_or(serde_json::Value::Object( + serde_json::Map::new(), + )); + let metadata = metadata_map + .get(id) + .cloned() + .unwrap_or_else(|| FeatureMetadata { + id: None, + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + }); + + let dockerfile_snippet = generate_layer(id, &dir_name, &options, &metadata); + layers.push(FeatureLayer { + id: id.clone(), + dir_name, + dockerfile_snippet, + }); + } + + Ok(layers) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::FeatureOption; + + #[test] + fn dir_name_from_full_id() { + assert_eq!( + dir_name_from_id("ghcr.io/devcontainers/features/node:1"), + "node" + ); + } + + #[test] + fn dir_name_from_id_no_tag() { + assert_eq!( + dir_name_from_id("ghcr.io/devcontainers/features/python"), + "python" + ); + } + + #[test] + fn dir_name_from_id_simple() { + assert_eq!(dir_name_from_id("node"), "node"); + } + + #[test] + fn topo_sort_no_dependencies() { + let ids = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let metadata: HashMap = ids + .iter() + .map(|id| { + ( + id.clone(), + FeatureMetadata { + id: Some(id.clone()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + }, + ) + }) + .collect(); + + let sorted = topo_sort(&ids, &metadata); + assert_eq!(sorted, vec!["a", "b", "c"]); + } + + #[test] + fn topo_sort_simple_chain() { + // A depends on B (A installs after B), so B should come first + let ids = vec!["a".to_string(), "b".to_string()]; + let mut metadata: HashMap = HashMap::new(); + metadata.insert( + "a".to_string(), + FeatureMetadata { + id: Some("a".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: vec!["b".to_string()], + }, + ); + metadata.insert( + "b".to_string(), + FeatureMetadata { + id: Some("b".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + }, + ); + + let sorted = topo_sort(&ids, &metadata); + assert_eq!(sorted, vec!["b", "a"]); + } + + #[test] + fn topo_sort_diamond() { + // D depends on B and C; B and C depend on A + // Expected: A, B, C, D (or A, C, B, D — both valid, but we preserve input order for ties) + let ids = vec![ + "d".to_string(), + "b".to_string(), + "c".to_string(), + "a".to_string(), + ]; + let mut metadata: HashMap = HashMap::new(); + metadata.insert( + "a".to_string(), + FeatureMetadata { + id: Some("a".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + }, + ); + metadata.insert( + "b".to_string(), + FeatureMetadata { + id: Some("b".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: vec!["a".to_string()], + }, + ); + metadata.insert( + "c".to_string(), + FeatureMetadata { + id: Some("c".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: vec!["a".to_string()], + }, + ); + metadata.insert( + "d".to_string(), + FeatureMetadata { + id: Some("d".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: vec!["b".to_string(), "c".to_string()], + }, + ); + + let sorted = topo_sort(&ids, &metadata); + // A must come before B and C; B and C must come before D + let pos_a = sorted.iter().position(|x| x == "a").unwrap(); + let pos_b = sorted.iter().position(|x| x == "b").unwrap(); + let pos_c = sorted.iter().position(|x| x == "c").unwrap(); + let pos_d = sorted.iter().position(|x| x == "d").unwrap(); + assert!(pos_a < pos_b); + assert!(pos_a < pos_c); + assert!(pos_b < pos_d); + assert!(pos_c < pos_d); + } + + #[test] + fn generate_layer_with_options() { + let options = serde_json::json!({"version": "20"}); + let mut meta_options = HashMap::new(); + meta_options.insert( + "version".to_string(), + FeatureOption { + option_type: Some("string".to_string()), + default: Some(serde_json::Value::String("lts".to_string())), + description: Some("Node.js version".to_string()), + }, + ); + let metadata = FeatureMetadata { + id: Some("node".to_string()), + name: Some("Node.js".to_string()), + version: Some("1.0.0".to_string()), + options: meta_options, + installs_after: Vec::new(), + }; + + let snippet = generate_layer( + "ghcr.io/devcontainers/features/node:1", + "node", + &options, + &metadata, + ); + + assert!(snippet.contains("# Feature: ghcr.io/devcontainers/features/node:1")); + assert!(snippet.contains("COPY node/ /tmp/devcontainer-features/node/")); + assert!(snippet.contains("export VERSION=\"20\"")); + assert!(snippet.contains("chmod +x install.sh")); + assert!(snippet.contains("./install.sh")); + } + + #[test] + fn generate_layer_with_defaults() { + let options = serde_json::json!({}); + let mut meta_options = HashMap::new(); + meta_options.insert( + "version".to_string(), + FeatureOption { + option_type: Some("string".to_string()), + default: Some(serde_json::Value::String("lts".to_string())), + description: Some("Node.js version".to_string()), + }, + ); + let metadata = FeatureMetadata { + id: Some("node".to_string()), + name: None, + version: None, + options: meta_options, + installs_after: Vec::new(), + }; + + let snippet = generate_layer( + "ghcr.io/devcontainers/features/node:1", + "node", + &options, + &metadata, + ); + + // Default value "lts" should be used + assert!(snippet.contains("export VERSION=\"lts\"")); + } + + #[test] + fn generate_layer_no_options() { + let options = serde_json::json!({}); + let metadata = FeatureMetadata { + id: Some("common-utils".to_string()), + name: None, + version: None, + options: HashMap::new(), + installs_after: Vec::new(), + }; + + let snippet = generate_layer( + "ghcr.io/devcontainers/features/common-utils:1", + "common-utils", + &options, + &metadata, + ); + + assert!(snippet.contains("# Feature: ghcr.io/devcontainers/features/common-utils:1")); + assert!(snippet.contains("COPY common-utils/ /tmp/devcontainer-features/common-utils/")); + assert!(snippet.contains("chmod +x install.sh")); + assert!(!snippet.contains("export ")); + } + + #[tokio::test] + #[ignore = "requires oras"] + async fn fetch_feature_integration() { + let tmp = tempfile::tempdir().unwrap(); + let metadata = + fetch_feature("ghcr.io/devcontainers/features/node:1", tmp.path()) + .await + .unwrap(); + assert!(metadata.id.is_some()); + assert!(tmp.path().join("node/install.sh").exists()); + } + + #[tokio::test] + #[ignore = "requires oras"] + async fn resolve_features_integration() { + let tmp = tempfile::tempdir().unwrap(); + let mut features = HashMap::new(); + features.insert( + "ghcr.io/devcontainers/features/node:1".to_string(), + serde_json::json!({"version": "20"}), + ); + let layers = resolve_features(&features, tmp.path()).await.unwrap(); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].dir_name, "node"); + assert!(layers[0].dockerfile_snippet.contains("export VERSION=\"20\"")); + } +} diff --git a/crates/arc-devcontainer/src/jsonc.rs b/crates/arc-devcontainer/src/jsonc.rs new file mode 100644 index 000000000..14193b9b7 --- /dev/null +++ b/crates/arc-devcontainer/src/jsonc.rs @@ -0,0 +1,280 @@ +/// Strip JSONC comments and trailing commas, producing valid JSON. +pub fn strip_jsonc(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let bytes = input.as_bytes(); + let len = bytes.len(); + let mut i = 0; + + while i < len { + match bytes[i] { + // String literal — copy verbatim (including any comment-like content) + b'"' => { + out.push('"'); + i += 1; + while i < len { + match bytes[i] { + b'\\' => { + // Escaped character — copy both backslash and next char + out.push('\\'); + i += 1; + if i < len { + out.push(bytes[i] as char); + i += 1; + } + } + b'"' => { + out.push('"'); + i += 1; + break; + } + _ => { + out.push(bytes[i] as char); + i += 1; + } + } + } + } + + // Potential comment start + b'/' if i + 1 < len => { + match bytes[i + 1] { + // Line comment — skip until end of line + b'/' => { + i += 2; + while i < len && bytes[i] != b'\n' { + i += 1; + } + } + // Block comment — skip until */ + b'*' => { + i += 2; + while i + 1 < len { + if bytes[i] == b'*' && bytes[i + 1] == b'/' { + i += 2; + break; + } + i += 1; + } + // Handle unterminated block comment at end of input + if i >= len { + break; + } + } + _ => { + out.push('/'); + i += 1; + } + } + } + + // Comma — check if it's a trailing comma before } or ] + b',' => { + // Look ahead past whitespace for } or ] + let mut j = i + 1; + while j < len && bytes[j].is_ascii_whitespace() { + j += 1; + } + // Also skip comments after the comma + while j < len { + if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'/' { + j += 2; + while j < len && bytes[j] != b'\n' { + j += 1; + } + while j < len && bytes[j].is_ascii_whitespace() { + j += 1; + } + } else if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'*' { + j += 2; + while j + 1 < len { + if bytes[j] == b'*' && bytes[j + 1] == b'/' { + j += 2; + break; + } + j += 1; + } + while j < len && bytes[j].is_ascii_whitespace() { + j += 1; + } + } else { + break; + } + } + + if j < len && (bytes[j] == b'}' || bytes[j] == b']') { + // Trailing comma — skip it + i += 1; + } else { + out.push(','); + i += 1; + } + } + + _ => { + out.push(bytes[i] as char); + i += 1; + } + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn passthrough_valid_json() { + let json = r#"{"key": "value"}"#; + assert_eq!(strip_jsonc(json), json); + } + + #[test] + fn strip_line_comments() { + let input = r#"{ + // this is a comment + "key": "value" +}"#; + // Leading whitespace on the comment line remains but that's valid JSON + let expected = "{\n \n \"key\": \"value\"\n}"; + assert_eq!(strip_jsonc(input), expected); + } + + #[test] + fn strip_line_comment_at_end_of_line() { + let input = r#"{"key": "value" // inline comment +}"#; + // Space before the comment remains + let expected = "{\"key\": \"value\" \n}"; + assert_eq!(strip_jsonc(input), expected); + } + + #[test] + fn strip_block_comments() { + let input = r#"{"key": /* comment */ "value"}"#; + let expected = r#"{"key": "value"}"#; + assert_eq!(strip_jsonc(input), expected); + } + + #[test] + fn strip_multiline_block_comment() { + let input = r#"{ + /* this is + a multi-line + comment */ + "key": "value" +}"#; + // Leading whitespace before block comment remains + let expected = "{\n \n \"key\": \"value\"\n}"; + assert_eq!(strip_jsonc(input), expected); + } + + #[test] + fn strip_trailing_comma_before_brace() { + let input = r#"{"a": 1, "b": 2,}"#; + let expected = r#"{"a": 1, "b": 2}"#; + assert_eq!(strip_jsonc(input), expected); + } + + #[test] + fn strip_trailing_comma_before_bracket() { + let input = r#"[1, 2, 3,]"#; + let expected = r#"[1, 2, 3]"#; + assert_eq!(strip_jsonc(input), expected); + } + + #[test] + fn trailing_comma_with_whitespace() { + let input = r#"{ + "a": 1, + "b": 2, +}"#; + let expected = r#"{ + "a": 1, + "b": 2 +}"#; + assert_eq!(strip_jsonc(input), expected); + } + + #[test] + fn comments_inside_strings_preserved() { + let input = r#"{"key": "value // not a comment"}"#; + assert_eq!(strip_jsonc(input), input); + } + + #[test] + fn block_comment_inside_string_preserved() { + let input = r#"{"key": "value /* not a comment */ still here"}"#; + assert_eq!(strip_jsonc(input), input); + } + + #[test] + fn mixed_comments_and_trailing_commas() { + let input = r#"{ + // first comment + "name": "test", /* inline */ + "items": [ + 1, + 2, // trailing + ], +}"#; + // Whitespace around stripped comments remains; trailing commas removed + let expected = "{\n \n \"name\": \"test\", \n \"items\": [\n 1,\n 2 \n ]\n}"; + assert_eq!(strip_jsonc(input), expected); + } + + #[test] + fn empty_input() { + assert_eq!(strip_jsonc(""), ""); + } + + #[test] + fn escaped_quote_in_string() { + let input = r#"{"key": "val\"ue // not a comment"}"#; + assert_eq!(strip_jsonc(input), input); + } + + #[test] + fn trailing_comma_with_comment_before_close() { + let input = r#"{"a": 1, // comment +}"#; + // Trailing comma removed; space before comment remains + let expected = "{\"a\": 1 \n}"; + assert_eq!(strip_jsonc(input), expected); + } + + #[test] + fn trailing_comma_with_block_comment_before_close() { + let input = r#"{"a": 1, /* comment */ }"#; + // Trailing comma removed; spaces around stripped comment remain + let expected = "{\"a\": 1 }"; + assert_eq!(strip_jsonc(input), expected); + } + + #[test] + fn only_comments() { + let input = "// just a comment\n/* block */"; + let expected = "\n"; + assert_eq!(strip_jsonc(input), expected); + } + + #[test] + fn produces_valid_json() { + let input = r#"{ + // devcontainer settings + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/rust:1": {}, + }, + /* forwarded ports */ + "forwardPorts": [3000, 8080,], + "remoteEnv": { + "EDITOR": "code", // default editor + }, +}"#; + let result = strip_jsonc(input); + let parsed: serde_json::Result = serde_json::from_str(&result); + assert!(parsed.is_ok(), "should produce valid JSON, got: {result}"); + } +} diff --git a/crates/arc-devcontainer/src/lib.rs b/crates/arc-devcontainer/src/lib.rs new file mode 100644 index 000000000..70997abda --- /dev/null +++ b/crates/arc-devcontainer/src/lib.rs @@ -0,0 +1,337 @@ +mod compose; +mod dockerfile; +mod features; +mod jsonc; +mod types; +mod variables; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +pub use types::DevcontainerJson; + +/// Lifecycle command — string, array, or object (parallel) form. +#[derive(Debug, Clone, PartialEq)] +pub enum Command { + Shell(String), + Args(Vec), + Parallel(HashMap), +} + +/// Parsed and resolved devcontainer configuration — everything needed to create a sandbox. +#[derive(Debug, Clone)] +pub struct DevcontainerConfig { + /// Generated Dockerfile content + pub dockerfile: String, + /// Directory for docker build context + pub build_context: PathBuf, + /// Run on host before build + pub initialize_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, + 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, + pub compose_service: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum DevcontainerError { + #[error("no devcontainer.json found in {0}")] + NotFound(PathBuf), + + #[error("parsing devcontainer.json: {0}")] + Parse(#[from] serde_json::Error), + + #[error("reading file {path}: {source}")] + ReadFile { + path: PathBuf, + source: std::io::Error, + }, + + #[error("compose file error: {0}")] + Compose(String), + + #[error("feature error: {0}")] + Feature(String), + + #[error("oras not found and auto-install failed: {0}")] + OrasInstall(String), + + #[error("oras command failed: {0}")] + OrasCommand(String), + + #[error("variable substitution error: {0}")] + Variable(String), +} + +pub type Result = std::result::Result; + +/// Parse and resolve a devcontainer config from a repo directory. +pub struct DevcontainerResolver; + +impl DevcontainerResolver { + /// path: repo root (or explicit .devcontainer/ path) + pub async fn resolve(path: &Path) -> Result { + let (json_path, devcontainer) = Self::find_and_parse(path)?; + let repo_root = Self::repo_root_from_json_path(&json_path, path); + let base_dir = json_path.parent().unwrap_or(path); + + let repo_name = repo_root + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("workspace") + .to_string(); + + // Variable substitution — two-pass: first resolve workspace_folder itself, + // then create final context with the resolved value. + let raw_workspace_folder = devcontainer + .workspace_folder + .clone() + .unwrap_or_else(|| format!("/workspaces/{repo_name}")); + + let preliminary_vars = variables::VariableContext { + local_workspace_folder: repo_root.to_string_lossy().to_string(), + local_workspace_folder_basename: repo_name.clone(), + container_workspace_folder: raw_workspace_folder.clone(), + }; + let workspace_folder = variables::substitute(&raw_workspace_folder, &preliminary_vars); + + let vars = variables::VariableContext { + local_workspace_folder: repo_root.to_string_lossy().to_string(), + local_workspace_folder_basename: repo_name.clone(), + container_workspace_folder: workspace_folder.clone(), + }; + + // Handle compose mode + if let Some(compose_ref) = &devcontainer.docker_compose_file { + let compose_path = base_dir.join(variables::substitute(compose_ref, &vars)); + let service_name = devcontainer + .service + .as_ref() + .ok_or_else(|| { + DevcontainerError::Compose( + "dockerComposeFile requires 'service' field".to_string(), + ) + })? + .clone(); + + let compose_config = + compose::parse_compose(&compose_path, &service_name).map_err(|e| { + DevcontainerError::Compose(format!("{}: {e}", compose_path.display())) + })?; + + let mut environment = HashMap::new(); + for (k, v) in compose_config.environment { + environment.insert(k, variables::substitute(&v, &vars)); + } + if let Some(env) = &devcontainer.remote_env { + for (k, v) in env { + environment.insert(k.clone(), variables::substitute(v, &vars)); + } + } + + let dockerfile = if let Some(build) = &compose_config.build { + let df_path = compose_path + .parent() + .unwrap_or(base_dir) + .join(&build.context) + .join(build.dockerfile.as_deref().unwrap_or("Dockerfile")); + std::fs::read_to_string(&df_path).map_err(|source| { + DevcontainerError::ReadFile { + path: df_path, + source, + } + })? + } else { + format!("FROM {}", compose_config.image.as_deref().unwrap_or("ubuntu")) + }; + + return Ok(DevcontainerConfig { + dockerfile, + build_context: compose_path + .parent() + .unwrap_or(base_dir) + .to_path_buf(), + initialize_commands: Self::collect_commands(&devcontainer.initialize_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, + remote_user: devcontainer + .remote_user + .clone() + .or(compose_config.user), + workspace_folder, + forwarded_ports: compose_config.ports, + compose_file: Some(compose_path), + compose_service: Some(service_name), + }); + } + + // Image or Dockerfile mode + let (base_dockerfile, build_context) = if let Some(build) = &devcontainer.build { + let context_dir = build + .context + .as_ref() + .map(|c| base_dir.join(variables::substitute(c, &vars))) + .unwrap_or_else(|| base_dir.to_path_buf()); + let df_path = base_dir.join(variables::substitute( + 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) + } else { + let image = devcontainer + .image + .as_deref() + .unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu"); + (format!("FROM {image}"), base_dir.to_path_buf()) + }; + + // Features + let feature_layers = if !devcontainer.features.is_empty() { + features::resolve_features(&devcontainer.features, &build_context).await? + } else { + Vec::new() + }; + + // Generate final Dockerfile + let dockerfile_content = dockerfile::generate( + &base_dockerfile, + &feature_layers, + &devcontainer.remote_env, + devcontainer.remote_user.as_deref(), + ); + + let mut environment = HashMap::new(); + if let Some(env) = &devcontainer.remote_env { + for (k, v) in env { + environment.insert(k.clone(), variables::substitute(v, &vars)); + } + } + + 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(); + + Ok(DevcontainerConfig { + dockerfile: dockerfile_content, + build_context, + initialize_commands: Self::collect_commands(&devcontainer.initialize_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, + remote_user: devcontainer.remote_user.clone(), + workspace_folder, + forwarded_ports, + compose_file: None, + compose_service: None, + }) + } + + fn find_and_parse(path: &Path) -> Result<(PathBuf, DevcontainerJson)> { + // Check standard locations + let candidates = [ + path.join(".devcontainer/devcontainer.json"), + path.join(".devcontainer.json"), + ]; + + for candidate in &candidates { + if candidate.exists() { + let raw = std::fs::read_to_string(candidate).map_err(|source| { + DevcontainerError::ReadFile { + path: candidate.clone(), + source, + } + })?; + let stripped = jsonc::strip_jsonc(&raw); + let parsed: DevcontainerJson = serde_json::from_str(&stripped)?; + return Ok((candidate.clone(), parsed)); + } + } + + // Check if path itself is a devcontainer.json + if path.is_file() + && path + .file_name() + .is_some_and(|n| n == "devcontainer.json") + { + let raw = std::fs::read_to_string(path).map_err(|source| { + DevcontainerError::ReadFile { + path: path.to_path_buf(), + source, + } + })?; + let stripped = jsonc::strip_jsonc(&raw); + let parsed: DevcontainerJson = serde_json::from_str(&stripped)?; + return Ok((path.to_path_buf(), parsed)); + } + + Err(DevcontainerError::NotFound(path.to_path_buf())) + } + + fn repo_root_from_json_path<'a>(json_path: &Path, original_path: &'a Path) -> &'a Path { + // If json_path is inside .devcontainer/, the repo root is one level up + if let Some(parent) = json_path.parent() { + if parent.file_name().is_some_and(|n| n == ".devcontainer") { + if let Some(repo_root) = parent.parent() { + // Only return repo_root if it matches the original path structure + let _ = repo_root; + } + } + } + original_path + } + + fn collect_commands( + cmd: &Option, + vars: &variables::VariableContext, + ) -> Vec { + match cmd { + None => Vec::new(), + Some(types::LifecycleCommand::String(s)) => { + vec![Command::Shell(variables::substitute(s, vars))] + } + Some(types::LifecycleCommand::Array(arr)) => { + vec![Command::Args( + arr.iter() + .map(|s| variables::substitute(s, vars)) + .collect(), + )] + } + Some(types::LifecycleCommand::Object(map)) => { + vec![Command::Parallel( + map.iter() + .map(|(k, v)| (k.clone(), variables::substitute(v, vars))) + .collect(), + )] + } + } + } +} diff --git a/crates/arc-devcontainer/src/types.rs b/crates/arc-devcontainer/src/types.rs new file mode 100644 index 000000000..83721a15d --- /dev/null +++ b/crates/arc-devcontainer/src/types.rs @@ -0,0 +1,225 @@ +use serde::Deserialize; +use std::collections::HashMap; + +/// Top-level devcontainer.json schema (subset of the spec we support). +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct DevcontainerJson { + /// Base image (image mode) + pub image: Option, + + /// Dockerfile build config + pub build: Option, + + /// Docker Compose file path (compose mode) + pub docker_compose_file: Option, + + /// Service name for compose mode + pub service: Option, + + /// Features to install: feature ID → options object + #[serde(default)] + pub features: HashMap, + + /// Ports to forward + #[serde(default, alias = "forwardPorts")] + pub forward_ports: Vec, + + /// Environment variables set in the container + #[serde(default)] + pub remote_env: Option>, + + /// Environment variables set in the container (containerEnv) + #[serde(default)] + pub container_env: Option>, + + /// Non-root user to run as + pub remote_user: Option, + + /// Container user + pub container_user: Option, + + /// Workspace folder path inside container + pub workspace_folder: Option, + + /// Workspace mount string + pub workspace_mount: Option, + + /// Run on host before anything else + pub initialize_command: Option, + + /// Run in container after first creation + pub post_create_command: Option, + + /// Run in container on every start + pub post_start_command: Option, + + /// Override the default command + pub override_command: Option, +} + +/// Build configuration for Dockerfile mode. +#[derive(Debug, Clone, Deserialize)] +pub struct BuildConfig { + /// Path to Dockerfile (relative to devcontainer.json) + pub dockerfile: Option, + + /// Build context directory (relative to devcontainer.json) + pub context: Option, + + /// Build arguments + #[serde(default)] + pub args: HashMap, +} + +/// A lifecycle command can be a string, array of strings, or object of named commands. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum LifecycleCommand { + String(String), + Array(Vec), + Object(HashMap), +} + +/// Metadata from a devcontainer-feature.json file. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeatureMetadata { + pub id: Option, + pub name: Option, + pub version: Option, + + #[serde(default)] + pub options: HashMap, + + /// Feature IDs that this feature should be installed after + #[serde(default)] + pub installs_after: Vec, +} + +/// A single option for a devcontainer feature. +#[derive(Debug, Clone, Deserialize)] +pub struct FeatureOption { + #[serde(rename = "type")] + pub option_type: Option, + pub default: Option, + pub description: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_image_only() { + let json = r#"{"image": "mcr.microsoft.com/devcontainers/base:ubuntu"}"#; + let config: DevcontainerJson = serde_json::from_str(json).unwrap(); + assert_eq!( + config.image.as_deref(), + Some("mcr.microsoft.com/devcontainers/base:ubuntu") + ); + } + + #[test] + fn parse_with_features() { + let json = r#"{ + "image": "ubuntu", + "features": { + "ghcr.io/devcontainers/features/node:1": {"version": "20"}, + "ghcr.io/devcontainers/features/python:1": {} + } + }"#; + let config: DevcontainerJson = serde_json::from_str(json).unwrap(); + assert_eq!(config.features.len(), 2); + } + + #[test] + fn parse_lifecycle_string() { + let json = r#"{"postCreateCommand": "npm install"}"#; + let config: DevcontainerJson = serde_json::from_str(json).unwrap(); + assert!(matches!( + config.post_create_command, + Some(LifecycleCommand::String(ref s)) if s == "npm install" + )); + } + + #[test] + fn parse_lifecycle_array() { + let json = r#"{"postCreateCommand": ["npm", "install"]}"#; + let config: DevcontainerJson = serde_json::from_str(json).unwrap(); + assert!(matches!( + config.post_create_command, + Some(LifecycleCommand::Array(ref arr)) if arr == &["npm", "install"] + )); + } + + #[test] + fn parse_lifecycle_object() { + let json = r#"{"postCreateCommand": {"install": "npm install", "build": "npm run build"}}"#; + let config: DevcontainerJson = serde_json::from_str(json).unwrap(); + assert!(matches!( + config.post_create_command, + Some(LifecycleCommand::Object(ref map)) if map.len() == 2 + )); + } + + #[test] + fn parse_build_config() { + let json = r#"{ + "build": { + "dockerfile": "Dockerfile", + "context": "..", + "args": {"VARIANT": "3.9"} + } + }"#; + let config: DevcontainerJson = serde_json::from_str(json).unwrap(); + let build = config.build.unwrap(); + 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")); + } + + #[test] + fn parse_compose_mode() { + let json = r#"{ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace" + }"#; + let config: DevcontainerJson = serde_json::from_str(json).unwrap(); + assert_eq!( + config.docker_compose_file.as_deref(), + Some("docker-compose.yml") + ); + assert_eq!(config.service.as_deref(), Some("app")); + assert_eq!(config.workspace_folder.as_deref(), Some("/workspace")); + } + + #[test] + fn unknown_fields_ignored() { + let json = r#"{"image": "ubuntu", "unknownField": true, "customizations": {}}"#; + let config: DevcontainerJson = serde_json::from_str(json).unwrap(); + assert_eq!(config.image.as_deref(), Some("ubuntu")); + } + + #[test] + fn parse_feature_metadata() { + let json = r#"{ + "id": "node", + "name": "Node.js", + "version": "1.0.0", + "options": { + "version": { + "type": "string", + "default": "lts", + "description": "Node.js version" + } + }, + "installsAfter": ["ghcr.io/devcontainers/features/common-utils"] + }"#; + let meta: FeatureMetadata = serde_json::from_str(json).unwrap(); + assert_eq!(meta.id.as_deref(), Some("node")); + assert_eq!(meta.options.len(), 1); + assert_eq!(meta.installs_after.len(), 1); + } +} diff --git a/crates/arc-devcontainer/src/variables.rs b/crates/arc-devcontainer/src/variables.rs new file mode 100644 index 000000000..b4d241cb6 --- /dev/null +++ b/crates/arc-devcontainer/src/variables.rs @@ -0,0 +1,223 @@ +/// Context for variable substitution. +pub struct VariableContext { + pub local_workspace_folder: String, + pub local_workspace_folder_basename: String, + pub container_workspace_folder: String, +} + +/// Replace devcontainer variables in a string value. +pub fn substitute(input: &str, ctx: &VariableContext) -> String { + let mut result = String::with_capacity(input.len()); + let mut rest = input; + + while let Some(start) = rest.find("${") { + result.push_str(&rest[..start]); + let after_open = &rest[start + 2..]; + + if let Some(close) = after_open.find('}') { + let expr = &after_open[..close]; + let replacement = resolve_variable(expr, ctx); + match replacement { + Some(val) => result.push_str(&val), + None => { + // Unknown variable — leave as-is + result.push_str(&rest[start..start + 2 + close + 1]); + } + } + rest = &after_open[close + 1..]; + } else { + // No closing brace — copy literally + result.push_str(&rest[start..]); + rest = ""; + } + } + + result.push_str(rest); + result +} + +fn resolve_variable(expr: &str, ctx: &VariableContext) -> Option { + match expr { + "localWorkspaceFolder" => Some(ctx.local_workspace_folder.clone()), + "localWorkspaceFolderBasename" => Some(ctx.local_workspace_folder_basename.clone()), + "containerWorkspaceFolder" => Some(ctx.container_workspace_folder.clone()), + "containerWorkspaceFolderBasename" => { + let basename = ctx + .container_workspace_folder + .rsplit('/') + .next() + .unwrap_or(&ctx.container_workspace_folder); + Some(basename.to_string()) + } + _ if expr.starts_with("localEnv:") => { + let var_part = &expr["localEnv:".len()..]; + // Split on first colon for default value + if let Some(colon_pos) = var_part.find(':') { + let var_name = &var_part[..colon_pos]; + let default = &var_part[colon_pos + 1..]; + Some(std::env::var(var_name).unwrap_or_else(|_| default.to_string())) + } else { + Some(std::env::var(var_part).unwrap_or_default()) + } + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_ctx() -> VariableContext { + VariableContext { + local_workspace_folder: "/home/user/project".to_string(), + local_workspace_folder_basename: "project".to_string(), + container_workspace_folder: "/workspaces/project".to_string(), + } + } + + #[test] + fn no_variables() { + let ctx = test_ctx(); + assert_eq!(substitute("hello", &ctx), "hello"); + } + + #[test] + fn local_workspace_folder() { + let ctx = test_ctx(); + assert_eq!( + substitute("${localWorkspaceFolder}/src", &ctx), + "/home/user/project/src" + ); + } + + #[test] + fn local_workspace_folder_basename() { + let ctx = test_ctx(); + assert_eq!( + substitute("name: ${localWorkspaceFolderBasename}", &ctx), + "name: project" + ); + } + + #[test] + fn container_workspace_folder() { + let ctx = test_ctx(); + assert_eq!( + substitute("${containerWorkspaceFolder}/app", &ctx), + "/workspaces/project/app" + ); + } + + #[test] + fn container_workspace_folder_basename() { + let ctx = test_ctx(); + assert_eq!( + substitute("${containerWorkspaceFolderBasename}", &ctx), + "project" + ); + } + + #[test] + fn container_workspace_folder_basename_nested() { + let ctx = VariableContext { + local_workspace_folder: "/home/user/repos/my-app".to_string(), + local_workspace_folder_basename: "my-app".to_string(), + container_workspace_folder: "/workspaces/repos/my-app".to_string(), + }; + assert_eq!( + substitute("${containerWorkspaceFolderBasename}", &ctx), + "my-app" + ); + } + + #[test] + fn multiple_variables() { + let ctx = test_ctx(); + assert_eq!( + substitute( + "${localWorkspaceFolder} and ${containerWorkspaceFolder}", + &ctx + ), + "/home/user/project and /workspaces/project" + ); + } + + #[test] + fn unknown_variable_left_as_is() { + let ctx = test_ctx(); + assert_eq!( + substitute("${unknownVariable}", &ctx), + "${unknownVariable}" + ); + } + + #[test] + fn local_env_with_set_variable() { + let ctx = test_ctx(); + std::env::set_var("ARC_TEST_VAR_SET", "hello"); + assert_eq!( + substitute("${localEnv:ARC_TEST_VAR_SET}", &ctx), + "hello" + ); + std::env::remove_var("ARC_TEST_VAR_SET"); + } + + #[test] + fn local_env_unset_returns_empty() { + let ctx = test_ctx(); + std::env::remove_var("ARC_TEST_VAR_UNSET_123"); + assert_eq!( + substitute("${localEnv:ARC_TEST_VAR_UNSET_123}", &ctx), + "" + ); + } + + #[test] + fn local_env_with_default_when_unset() { + let ctx = test_ctx(); + std::env::remove_var("ARC_TEST_VAR_DEFAULT_456"); + assert_eq!( + substitute("${localEnv:ARC_TEST_VAR_DEFAULT_456:fallback}", &ctx), + "fallback" + ); + } + + #[test] + fn local_env_with_default_when_set() { + let ctx = test_ctx(); + std::env::set_var("ARC_TEST_VAR_DEFAULT_SET", "actual"); + assert_eq!( + substitute("${localEnv:ARC_TEST_VAR_DEFAULT_SET:fallback}", &ctx), + "actual" + ); + std::env::remove_var("ARC_TEST_VAR_DEFAULT_SET"); + } + + #[test] + fn no_closing_brace() { + let ctx = test_ctx(); + assert_eq!(substitute("${localWorkspaceFolder", &ctx), "${localWorkspaceFolder"); + } + + #[test] + fn empty_input() { + let ctx = test_ctx(); + assert_eq!(substitute("", &ctx), ""); + } + + #[test] + fn dollar_without_brace() { + let ctx = test_ctx(); + assert_eq!(substitute("$notavar", &ctx), "$notavar"); + } + + #[test] + fn adjacent_variables() { + let ctx = test_ctx(); + assert_eq!( + substitute("${localWorkspaceFolderBasename}${containerWorkspaceFolderBasename}", &ctx), + "projectproject" + ); + } +} diff --git a/crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json new file mode 100644 index 000000000..ab1450dc0 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json @@ -0,0 +1,10 @@ +{ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace", + "remoteUser": "node", + "postCreateCommand": "npm install", + "remoteEnv": { + "NODE_ENV": "development" + } +} diff --git a/crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/docker-compose.yml b/crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/docker-compose.yml new file mode 100644 index 000000000..b8726871c --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/docker-compose.yml @@ -0,0 +1,13 @@ +services: + app: + image: node:20 + ports: + - "3000:3000" + - "9229:9229" + environment: + - "NODE_ENV=development" + - "DEBUG=true" + db: + image: postgres:15 + ports: + - "5432:5432" diff --git a/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/Dockerfile b/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/Dockerfile new file mode 100644 index 000000000..3c45d3a1a --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/Dockerfile @@ -0,0 +1,3 @@ +FROM node:20 +RUN apt-get update && apt-get install -y git +WORKDIR /workspace diff --git a/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json new file mode 100644 index 000000000..9deb26cf1 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json @@ -0,0 +1,10 @@ +{ + // This is a JSONC file with comments + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + "remoteUser": "developer", + "postCreateCommand": "npm install", + "forwardPorts": [4000], +} diff --git a/crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json new file mode 100644 index 000000000..68608b4a8 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json @@ -0,0 +1,9 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "forwardPorts": [3000, 8080], + "remoteUser": "vscode", + "remoteEnv": { + "EDITOR": "code" + }, + "postCreateCommand": "echo hello" +} diff --git a/crates/arc-devcontainer/tests/fixtures/variables/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/variables/.devcontainer/devcontainer.json new file mode 100644 index 000000000..63083ea09 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/variables/.devcontainer/devcontainer.json @@ -0,0 +1,9 @@ +{ + "image": "ubuntu:22.04", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "remoteEnv": { + "PROJECT_ROOT": "${containerWorkspaceFolder}", + "PROJECT_NAME": "${containerWorkspaceFolderBasename}" + }, + "postCreateCommand": "echo ${containerWorkspaceFolder}" +} diff --git a/crates/arc-devcontainer/tests/fixtures/with-features/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/with-features/devcontainer.json new file mode 100644 index 000000000..2d477b073 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/with-features/devcontainer.json @@ -0,0 +1,9 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "20" + }, + "ghcr.io/devcontainers/features/python:1": {} + } +} diff --git a/crates/arc-devcontainer/tests/integration.rs b/crates/arc-devcontainer/tests/integration.rs new file mode 100644 index 000000000..c626fa54e --- /dev/null +++ b/crates/arc-devcontainer/tests/integration.rs @@ -0,0 +1,102 @@ +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) +} + +#[tokio::test] +async fn resolve_image_only() { + let config = DevcontainerResolver::resolve(&fixture_path("image-only")) + .await + .unwrap(); + + 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.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_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")); +} + +#[tokio::test] +async fn resolve_dockerfile_mode() { + let config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode")) + .await + .unwrap(); + + // Should read the actual Dockerfile content + assert!(config.dockerfile.contains("FROM node:20")); + assert!(config.dockerfile.contains("apt-get update")); + assert_eq!(config.remote_user.as_deref(), Some("developer")); + assert_eq!(config.forwarded_ports, vec![4000]); + + assert_eq!(config.post_create_commands.len(), 1); + assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "npm install")); +} + +#[tokio::test] +async fn resolve_compose_mode() { + let config = DevcontainerResolver::resolve(&fixture_path("compose-mode")) + .await + .unwrap(); + + // In compose mode, the dockerfile is derived from the compose service's image + 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_service.as_deref(), Some("app")); + + // Ports come from compose + remoteEnv merged + assert_eq!(config.forwarded_ports, vec![3000, 9229]); + + // Environment merged from compose + 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")); +} + +#[tokio::test] +async fn resolve_variables() { + let config = DevcontainerResolver::resolve(&fixture_path("variables")) + .await + .unwrap(); + + assert_eq!(config.workspace_folder, "/workspaces/variables"); + assert_eq!( + config.environment.get("PROJECT_ROOT").map(String::as_str), + Some("/workspaces/variables") + ); + assert_eq!( + config.environment.get("PROJECT_NAME").map(String::as_str), + Some("variables") + ); +} + +#[tokio::test] +async fn resolve_not_found() { + let result = DevcontainerResolver::resolve(&fixture_path("nonexistent")).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("no devcontainer.json found")); +} + +#[tokio::test] +async fn generated_dockerfile_is_well_formed() { + let config = DevcontainerResolver::resolve(&fixture_path("image-only")) + .await + .unwrap(); + + // Should start with the generated header + assert!(config.dockerfile.contains("# Generated by arc-devcontainer")); + // Should have the base image + assert!(config.dockerfile.contains("FROM")); + // Should end with a newline + assert!(config.dockerfile.ends_with('\n')); +}