mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
ffac356c7b
commit
5e337b4cb3
19 changed files with 2680 additions and 0 deletions
13
Cargo.lock
generated
13
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
21
crates/arc-devcontainer/Cargo.toml
Normal file
21
crates/arc-devcontainer/Cargo.toml
Normal file
|
|
@ -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"
|
||||
114
crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md
Normal file
114
crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md
Normal file
|
|
@ -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. `<path>/.devcontainer/devcontainer.json`
|
||||
2. `<path>/.devcontainer.json`
|
||||
3. Direct path if it ends in `devcontainer.json`
|
||||
186
crates/arc-devcontainer/INTEGRATION.md
Normal file
186
crates/arc-devcontainer/INTEGRATION.md
Normal file
|
|
@ -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<Command>, // Host-side pre-build commands
|
||||
pub post_create_commands: Vec<Command>, // Container post-creation setup
|
||||
pub post_start_commands: Vec<Command>, // Container on-each-start commands
|
||||
pub environment: HashMap<String, String>, // remoteEnv merged
|
||||
pub remote_user: Option<String>, // Non-root user
|
||||
pub workspace_folder: String, // Working directory inside container
|
||||
pub forwarded_ports: Vec<u16>, // Ports to expose
|
||||
pub compose_file: Option<PathBuf>, // Set when in compose mode
|
||||
pub compose_service: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
## 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<String>), // ["npm", "install"]
|
||||
Parallel(HashMap<String, String>), // {"install": "npm install", "build": "npm run build"}
|
||||
}
|
||||
```
|
||||
|
||||
- `Shell` -- execute via `sh -c "<command>"`
|
||||
- `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<Sandbox> {
|
||||
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.
|
||||
304
crates/arc-devcontainer/src/compose.rs
Normal file
304
crates/arc-devcontainer/src/compose.rs
Normal file
|
|
@ -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<String>,
|
||||
pub build: Option<ComposeBuild>,
|
||||
pub ports: Vec<u16>,
|
||||
pub environment: HashMap<String, String>,
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// Build configuration from a Docker Compose service.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ComposeBuild {
|
||||
pub context: String,
|
||||
pub dockerfile: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a Docker Compose file and extract config for the named service.
|
||||
pub fn parse_compose(
|
||||
compose_path: &Path,
|
||||
service_name: &str,
|
||||
) -> Result<ComposeServiceConfig, String> {
|
||||
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<ComposeBuild> {
|
||||
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<u16> {
|
||||
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::<u16>().ok()
|
||||
} else {
|
||||
s.parse::<u16>().ok()
|
||||
};
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_environment(service: &serde_yaml::Value) -> HashMap<String, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
171
crates/arc-devcontainer/src/dockerfile.rs
Normal file
171
crates/arc-devcontainer/src/dockerfile.rs
Normal file
|
|
@ -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<HashMap<String, String>>,
|
||||
remote_user: Option<&str>,
|
||||
) -> String {
|
||||
let mut sections: Vec<String> = 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<String> = 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'));
|
||||
}
|
||||
}
|
||||
641
crates/arc-devcontainer/src/features.rs
Normal file
641
crates/arc-devcontainer/src/features.rs
Normal file
|
|
@ -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<FeatureMetadata> {
|
||||
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<String, FeatureMetadata>,
|
||||
) -> Vec<String> {
|
||||
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<String> = 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<String, String> = 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<String, serde_json::Value>,
|
||||
_build_context: &Path,
|
||||
) -> crate::Result<Vec<FeatureLayer>> {
|
||||
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<String> = features.keys().cloned().collect();
|
||||
|
||||
// Fetch all features and collect metadata
|
||||
let mut metadata_map: HashMap<String, FeatureMetadata> = 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<String, FeatureMetadata> = 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<String, FeatureMetadata> = 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<String, FeatureMetadata> = 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\""));
|
||||
}
|
||||
}
|
||||
280
crates/arc-devcontainer/src/jsonc.rs
Normal file
280
crates/arc-devcontainer/src/jsonc.rs
Normal file
|
|
@ -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::Value> = serde_json::from_str(&result);
|
||||
assert!(parsed.is_ok(), "should produce valid JSON, got: {result}");
|
||||
}
|
||||
}
|
||||
337
crates/arc-devcontainer/src/lib.rs
Normal file
337
crates/arc-devcontainer/src/lib.rs
Normal file
|
|
@ -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<String>),
|
||||
Parallel(HashMap<String, String>),
|
||||
}
|
||||
|
||||
/// 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<Command>,
|
||||
/// Run in container after creation
|
||||
pub post_create_commands: Vec<Command>,
|
||||
/// Run in container on each start
|
||||
pub post_start_commands: Vec<Command>,
|
||||
/// remoteEnv merged
|
||||
pub environment: HashMap<String, String>,
|
||||
pub remote_user: Option<String>,
|
||||
/// default: /workspaces/{repo-name}
|
||||
pub workspace_folder: String,
|
||||
/// first = default preview port
|
||||
pub forwarded_ports: Vec<u16>,
|
||||
/// if dockerComposeFile mode
|
||||
pub compose_file: Option<PathBuf>,
|
||||
pub compose_service: Option<String>,
|
||||
}
|
||||
|
||||
#[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<T> = std::result::Result<T, DevcontainerError>;
|
||||
|
||||
/// 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<DevcontainerConfig> {
|
||||
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<types::LifecycleCommand>,
|
||||
vars: &variables::VariableContext,
|
||||
) -> Vec<Command> {
|
||||
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(),
|
||||
)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
225
crates/arc-devcontainer/src/types.rs
Normal file
225
crates/arc-devcontainer/src/types.rs
Normal file
|
|
@ -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<String>,
|
||||
|
||||
/// Dockerfile build config
|
||||
pub build: Option<BuildConfig>,
|
||||
|
||||
/// Docker Compose file path (compose mode)
|
||||
pub docker_compose_file: Option<String>,
|
||||
|
||||
/// Service name for compose mode
|
||||
pub service: Option<String>,
|
||||
|
||||
/// Features to install: feature ID → options object
|
||||
#[serde(default)]
|
||||
pub features: HashMap<String, serde_json::Value>,
|
||||
|
||||
/// Ports to forward
|
||||
#[serde(default, alias = "forwardPorts")]
|
||||
pub forward_ports: Vec<serde_json::Value>,
|
||||
|
||||
/// Environment variables set in the container
|
||||
#[serde(default)]
|
||||
pub remote_env: Option<HashMap<String, String>>,
|
||||
|
||||
/// Environment variables set in the container (containerEnv)
|
||||
#[serde(default)]
|
||||
pub container_env: Option<HashMap<String, String>>,
|
||||
|
||||
/// Non-root user to run as
|
||||
pub remote_user: Option<String>,
|
||||
|
||||
/// Container user
|
||||
pub container_user: Option<String>,
|
||||
|
||||
/// Workspace folder path inside container
|
||||
pub workspace_folder: Option<String>,
|
||||
|
||||
/// Workspace mount string
|
||||
pub workspace_mount: Option<String>,
|
||||
|
||||
/// Run on host before anything else
|
||||
pub initialize_command: Option<LifecycleCommand>,
|
||||
|
||||
/// Run in container after first creation
|
||||
pub post_create_command: Option<LifecycleCommand>,
|
||||
|
||||
/// Run in container on every start
|
||||
pub post_start_command: Option<LifecycleCommand>,
|
||||
|
||||
/// Override the default command
|
||||
pub override_command: Option<bool>,
|
||||
}
|
||||
|
||||
/// Build configuration for Dockerfile mode.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BuildConfig {
|
||||
/// Path to Dockerfile (relative to devcontainer.json)
|
||||
pub dockerfile: Option<String>,
|
||||
|
||||
/// Build context directory (relative to devcontainer.json)
|
||||
pub context: Option<String>,
|
||||
|
||||
/// Build arguments
|
||||
#[serde(default)]
|
||||
pub args: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// 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<String>),
|
||||
Object(HashMap<String, String>),
|
||||
}
|
||||
|
||||
/// Metadata from a devcontainer-feature.json file.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FeatureMetadata {
|
||||
pub id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub version: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub options: HashMap<String, FeatureOption>,
|
||||
|
||||
/// Feature IDs that this feature should be installed after
|
||||
#[serde(default)]
|
||||
pub installs_after: Vec<String>,
|
||||
}
|
||||
|
||||
/// A single option for a devcontainer feature.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FeatureOption {
|
||||
#[serde(rename = "type")]
|
||||
pub option_type: Option<String>,
|
||||
pub default: Option<serde_json::Value>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
223
crates/arc-devcontainer/src/variables.rs
Normal file
223
crates/arc-devcontainer/src/variables.rs
Normal file
|
|
@ -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<String> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
10
crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json
vendored
Normal file
10
crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"remoteUser": "node",
|
||||
"postCreateCommand": "npm install",
|
||||
"remoteEnv": {
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
}
|
||||
13
crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/docker-compose.yml
vendored
Normal file
13
crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/docker-compose.yml
vendored
Normal file
|
|
@ -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"
|
||||
3
crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/Dockerfile
vendored
Normal file
3
crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/Dockerfile
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
FROM node:20
|
||||
RUN apt-get update && apt-get install -y git
|
||||
WORKDIR /workspace
|
||||
10
crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json
vendored
Normal file
10
crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
// This is a JSONC file with comments
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": ".."
|
||||
},
|
||||
"remoteUser": "developer",
|
||||
"postCreateCommand": "npm install",
|
||||
"forwardPorts": [4000],
|
||||
}
|
||||
9
crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json
vendored
Normal file
9
crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"forwardPorts": [3000, 8080],
|
||||
"remoteUser": "vscode",
|
||||
"remoteEnv": {
|
||||
"EDITOR": "code"
|
||||
},
|
||||
"postCreateCommand": "echo hello"
|
||||
}
|
||||
9
crates/arc-devcontainer/tests/fixtures/variables/.devcontainer/devcontainer.json
vendored
Normal file
9
crates/arc-devcontainer/tests/fixtures/variables/.devcontainer/devcontainer.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"image": "ubuntu:22.04",
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
"remoteEnv": {
|
||||
"PROJECT_ROOT": "${containerWorkspaceFolder}",
|
||||
"PROJECT_NAME": "${containerWorkspaceFolderBasename}"
|
||||
},
|
||||
"postCreateCommand": "echo ${containerWorkspaceFolder}"
|
||||
}
|
||||
9
crates/arc-devcontainer/tests/fixtures/with-features/devcontainer.json
vendored
Normal file
9
crates/arc-devcontainer/tests/fixtures/with-features/devcontainer.json
vendored
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
102
crates/arc-devcontainer/tests/integration.rs
Normal file
102
crates/arc-devcontainer/tests/integration.rs
Normal file
|
|
@ -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'));
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue