mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-05 08:10:39 +00:00
Fix 4 critical gaps in arc-devcontainer: onCreateCommand, build.args, containerEnv, compose array
- Add onCreateCommand lifecycle hook (parsed, resolved, exposed as on_create_commands) - Expose build.args on DevcontainerConfig for docker build --build-arg - Wire containerEnv into generated Dockerfile as ENV directives (remoteEnv overrides on collision) - Support dockerComposeFile as array of paths with merge semantics (last wins for image/build/user, ports accumulate, env overrides) - Update DEVCONTAINER-COMPATIBILITY.md and INTEGRATION.md docs - Add e2e tests with realistic Python and compose project fixtures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
74cd6bc791
commit
ee11a46b86
19 changed files with 696 additions and 73 deletions
|
|
@ -13,7 +13,7 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https
|
|||
| `portsAttributes` | No | Not parsed |
|
||||
| `otherPortsAttributes` | No | Not parsed |
|
||||
| `updateRemoteUserUID` | No | Not parsed |
|
||||
| `containerEnv` | Partial | Parsed in `DevcontainerJson::container_env` but not merged into `DevcontainerConfig::environment` |
|
||||
| `containerEnv` | Yes | Baked into generated Dockerfile as `ENV` directives; also exposed in `DevcontainerConfig::container_env` |
|
||||
| `remoteEnv` | Yes | Merged into `DevcontainerConfig::environment` with variable substitution |
|
||||
| `containerUser` | Partial | Parsed in `DevcontainerJson::container_user` but not exposed in `DevcontainerConfig` |
|
||||
| `remoteUser` | Yes | Exposed as `DevcontainerConfig::remote_user` |
|
||||
|
|
@ -33,7 +33,7 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https
|
|||
|---|---|---|
|
||||
| `build.dockerfile` | Yes | Resolved relative to devcontainer.json; content read and used as base Dockerfile |
|
||||
| `build.context` | Yes | Resolved with variable substitution; passed as `DevcontainerConfig::build_context` |
|
||||
| `build.args` | Partial | Parsed in `BuildConfig::args` but not injected into generated Dockerfile |
|
||||
| `build.args` | Yes | Parsed and exposed in `DevcontainerConfig::build_args` for passing to `docker build --build-arg` |
|
||||
| `build.target` | No | Not parsed |
|
||||
| `build.cacheFrom` | No | Not parsed |
|
||||
| `build.options` | No | Not parsed |
|
||||
|
|
@ -42,7 +42,7 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https
|
|||
|
||||
| Property | Status | Notes |
|
||||
|---|---|---|
|
||||
| `dockerComposeFile` | Partial | Single file path supported; array of paths not supported |
|
||||
| `dockerComposeFile` | Yes | Single path and array of paths supported; multiple files are merged (last wins for image/build/user; ports accumulate; environment overrides) |
|
||||
| `service` | Yes | Required when `dockerComposeFile` is set; used to extract service config |
|
||||
| `runServices` | No | Not parsed; all services assumed |
|
||||
| `shutdownAction` | No | Not parsed |
|
||||
|
|
@ -62,7 +62,7 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https
|
|||
| Property | Status | Notes |
|
||||
|---|---|---|
|
||||
| `initializeCommand` | Yes | All three forms supported: string, array, object (parallel). Exposed as `DevcontainerConfig::initialize_commands` |
|
||||
| `onCreateCommand` | No | Not parsed |
|
||||
| `onCreateCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::on_create_commands` |
|
||||
| `updateContentCommand` | No | Not parsed |
|
||||
| `postCreateCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::post_create_commands` |
|
||||
| `postStartCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::post_start_commands` |
|
||||
|
|
|
|||
|
|
@ -10,14 +10,17 @@ How to wire the parsed `DevcontainerConfig` into sandbox creation.
|
|||
pub struct DevcontainerConfig {
|
||||
pub dockerfile: String, // Generated Dockerfile content
|
||||
pub build_context: PathBuf, // Directory for docker build
|
||||
pub initialize_commands: Vec<Command>, // Host-side pre-build commands
|
||||
pub post_create_commands: Vec<Command>, // Container post-creation setup
|
||||
pub post_start_commands: Vec<Command>, // Container on-each-start commands
|
||||
pub environment: HashMap<String, String>, // remoteEnv merged
|
||||
pub build_args: HashMap<String, String>, // docker build --build-arg flags
|
||||
pub initialize_commands: Vec<Command>, // Host-side pre-build commands
|
||||
pub on_create_commands: Vec<Command>, // Container after first creation
|
||||
pub post_create_commands: Vec<Command>, // Container post-creation setup
|
||||
pub post_start_commands: Vec<Command>, // Container on-each-start commands
|
||||
pub environment: HashMap<String, String>, // remoteEnv merged
|
||||
pub container_env: HashMap<String, String>, // containerEnv (also in Dockerfile)
|
||||
pub remote_user: Option<String>, // Non-root user
|
||||
pub workspace_folder: String, // Working directory inside container
|
||||
pub forwarded_ports: Vec<u16>, // Ports to expose
|
||||
pub compose_file: Option<PathBuf>, // Set when in compose mode
|
||||
pub compose_files: Vec<PathBuf>, // Compose file paths (empty if not compose mode)
|
||||
pub compose_service: Option<String>,
|
||||
}
|
||||
```
|
||||
|
|
@ -33,10 +36,10 @@ pub struct DevcontainerConfig {
|
|||
|
||||
### Environment Variables
|
||||
|
||||
`config.environment` contains the merged `remoteEnv` values (with variables already substituted).
|
||||
`config.environment` contains the merged `remoteEnv` values (with variables already substituted). `config.container_env` contains `containerEnv` values (also baked into the generated Dockerfile as `ENV` directives).
|
||||
|
||||
- Pass these as environment variables when creating the sandbox.
|
||||
- `containerEnv` values (if supported in the future) would be baked into the Dockerfile via `ENV` directives.
|
||||
- Pass `config.environment` as environment variables when creating the sandbox.
|
||||
- `containerEnv` values are already in the Dockerfile; `config.container_env` is available for reference.
|
||||
|
||||
### Workspace Folder
|
||||
|
||||
|
|
@ -61,7 +64,7 @@ pub struct DevcontainerConfig {
|
|||
|
||||
## Docker Compose DinD Flow
|
||||
|
||||
When `config.compose_file` is `Some(path)`, the devcontainer uses Docker Compose mode.
|
||||
When `config.compose_files` is non-empty, the devcontainer uses Docker Compose mode.
|
||||
|
||||
### Strategy
|
||||
|
||||
|
|
@ -85,7 +88,7 @@ The devcontainer spec defines this execution order:
|
|||
| Hook | Where | When | `DevcontainerConfig` field |
|
||||
|---|---|---|---|
|
||||
| `initializeCommand` | Host | Before build | `initialize_commands` |
|
||||
| `onCreateCommand` | Container | After first creation | Not captured (not parsed) |
|
||||
| `onCreateCommand` | Container | After first creation | `on_create_commands` |
|
||||
| `updateContentCommand` | Container | After create/content update | Not captured (not parsed) |
|
||||
| `postCreateCommand` | Container | After create/content update | `post_create_commands` |
|
||||
| `postStartCommand` | Container | On each start | `post_start_commands` |
|
||||
|
|
@ -111,10 +114,11 @@ pub enum Command {
|
|||
|
||||
```
|
||||
1. Run initialize_commands on HOST (before sandbox creation)
|
||||
2. Build image from config.dockerfile
|
||||
2. Build image from config.dockerfile (pass config.build_args as --build-arg flags)
|
||||
3. Create sandbox from image
|
||||
4. Run post_create_commands in sandbox (as remote_user if set)
|
||||
5. Run post_start_commands in sandbox (as remote_user if set)
|
||||
4. Run on_create_commands in sandbox (as remote_user if set)
|
||||
5. Run post_create_commands in sandbox (as remote_user if set)
|
||||
6. Run post_start_commands in sandbox (as remote_user if set)
|
||||
```
|
||||
|
||||
## Example Integration Code
|
||||
|
|
@ -131,7 +135,7 @@ async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result<Sandbox> {
|
|||
}
|
||||
|
||||
// 2. Build image and create sandbox
|
||||
let sandbox = if config.compose_file.is_some() {
|
||||
let sandbox = if !config.compose_files.is_empty() {
|
||||
// Compose mode: build from extracted service Dockerfile, then run compose inside
|
||||
let sandbox = daytona.create_from_dockerfile(
|
||||
&config.dockerfile,
|
||||
|
|
@ -158,6 +162,9 @@ async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result<Sandbox> {
|
|||
|
||||
// 5. Run lifecycle hooks
|
||||
let user = config.remote_user.as_deref();
|
||||
for cmd in &config.on_create_commands {
|
||||
sandbox.exec_command(cmd, user).await?;
|
||||
}
|
||||
for cmd in &config.post_create_commands {
|
||||
sandbox.exec_command(cmd, user).await?;
|
||||
}
|
||||
|
|
@ -177,10 +184,7 @@ async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result<Sandbox> {
|
|||
## Edge Cases and Limitations
|
||||
|
||||
- **Features require `oras`**: Feature resolution shells out to `oras` CLI for OCI registry pulls. The resolver attempts auto-install if `oras` is not on PATH.
|
||||
- **`build.args` not injected**: Build arguments are parsed but not passed to `docker build` via `--build-arg` or `ARG` directives.
|
||||
- **`containerEnv` not merged**: Only `remoteEnv` is included in `DevcontainerConfig::environment`. `containerEnv` is parsed but not forwarded.
|
||||
- **Single compose file only**: `dockerComposeFile` is treated as a single string path. The spec allows an array of paths for compose file merging.
|
||||
- **No `onCreateCommand` or `updateContentCommand`**: These lifecycle hooks are not parsed. For first-run setup, `postCreateCommand` serves as the primary hook.
|
||||
- **No `updateContentCommand`**: This lifecycle hook is not parsed.
|
||||
- **No `postAttachCommand`**: Not parsed. Attach-time hooks would need to run on each user session connection.
|
||||
- **`${containerEnv:VAR}` not supported**: Variable substitution only covers host-side variables. Container-side env vars require a running container.
|
||||
- **Port forwarding is numeric only**: String port formats (e.g., `"label:3000"`) in `forwardPorts` are filtered out; only numeric values are extracted.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Extracted configuration from a Docker Compose service.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
|
|
@ -152,6 +152,60 @@ fn parse_environment(service: &serde_yaml::Value) -> HashMap<String, String> {
|
|||
HashMap::new()
|
||||
}
|
||||
|
||||
/// Parse multiple Docker Compose files and merge config for the named service.
|
||||
/// Later files override earlier files for image/build/user; ports accumulate (deduped);
|
||||
/// environment keys from later files override earlier ones.
|
||||
pub fn parse_compose_multi(
|
||||
compose_paths: &[PathBuf],
|
||||
service_name: &str,
|
||||
) -> Result<ComposeServiceConfig, String> {
|
||||
let mut merged = ComposeServiceConfig::default();
|
||||
let mut found_service = false;
|
||||
|
||||
for path in compose_paths {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read compose file {}: {e}", path.display()))?;
|
||||
|
||||
let doc: serde_yaml::Value = serde_yaml::from_str(&contents)
|
||||
.map_err(|e| format!("failed to parse YAML {}: {e}", path.display()))?;
|
||||
|
||||
let Some(service) = doc.get("services").and_then(|s| s.get(service_name)) else {
|
||||
continue;
|
||||
};
|
||||
found_service = true;
|
||||
|
||||
if let Some(image) = service.get("image").and_then(|v| v.as_str()) {
|
||||
merged.image = Some(image.to_string());
|
||||
}
|
||||
|
||||
if let Some(build) = parse_build(service) {
|
||||
merged.build = Some(build);
|
||||
}
|
||||
|
||||
if let Some(user) = service.get("user").and_then(|v| v.as_str()) {
|
||||
merged.user = Some(user.to_string());
|
||||
}
|
||||
|
||||
for port in parse_ports(service) {
|
||||
if !merged.ports.contains(&port) {
|
||||
merged.ports.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
for (k, v) in parse_environment(service) {
|
||||
merged.environment.insert(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
if !found_service {
|
||||
return Err(format!(
|
||||
"service '{service_name}' not found in any compose file"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -301,4 +355,72 @@ services:
|
|||
let cfg = parse_compose(f.path(), "app").unwrap();
|
||||
assert_eq!(cfg.user.as_deref(), Some("1000:1000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_compose_merge() {
|
||||
let base = write_compose(
|
||||
r#"
|
||||
services:
|
||||
app:
|
||||
image: node:20
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- "NODE_ENV=development"
|
||||
"#,
|
||||
);
|
||||
let over = write_compose(
|
||||
r#"
|
||||
services:
|
||||
app:
|
||||
image: node:22
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "9229:9229"
|
||||
environment:
|
||||
- "DEBUG=true"
|
||||
"#,
|
||||
);
|
||||
let paths = vec![base.path().to_path_buf(), over.path().to_path_buf()];
|
||||
let cfg = parse_compose_multi(&paths, "app").unwrap();
|
||||
assert_eq!(cfg.image.as_deref(), Some("node:22"));
|
||||
assert_eq!(cfg.ports, vec![3000, 9229]);
|
||||
assert_eq!(cfg.environment.get("NODE_ENV").unwrap(), "development");
|
||||
assert_eq!(cfg.environment.get("DEBUG").unwrap(), "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_compose_service_not_found() {
|
||||
let f = write_compose(
|
||||
r#"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
"#,
|
||||
);
|
||||
let paths = vec![f.path().to_path_buf()];
|
||||
let err = parse_compose_multi(&paths, "missing").unwrap_err();
|
||||
assert!(err.contains("service 'missing' not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_compose_skips_file_without_service() {
|
||||
let base = write_compose(
|
||||
r#"
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
"#,
|
||||
);
|
||||
let over = write_compose(
|
||||
r#"
|
||||
services:
|
||||
app:
|
||||
image: node:22
|
||||
"#,
|
||||
);
|
||||
let paths = vec![base.path().to_path_buf(), over.path().to_path_buf()];
|
||||
let cfg = parse_compose_multi(&paths, "app").unwrap();
|
||||
assert_eq!(cfg.image.as_deref(), Some("node:22"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use crate::features::FeatureLayer;
|
|||
pub fn generate(
|
||||
base_dockerfile: &str,
|
||||
feature_layers: &[FeatureLayer],
|
||||
container_env: &Option<HashMap<String, String>>,
|
||||
remote_env: &Option<HashMap<String, String>>,
|
||||
remote_user: Option<&str>,
|
||||
) -> String {
|
||||
|
|
@ -18,17 +19,27 @@ pub fn generate(
|
|||
sections.push(layer.dockerfile_snippet.clone());
|
||||
}
|
||||
|
||||
if let Some(env) = remote_env {
|
||||
if !env.is_empty() {
|
||||
let mut keys: Vec<&String> = env.keys().collect();
|
||||
keys.sort();
|
||||
let env_lines: Vec<String> = keys
|
||||
.iter()
|
||||
.map(|k| format!("ENV {}={}", k, env[*k]))
|
||||
.collect();
|
||||
sections.push(env_lines.join("\n"));
|
||||
// containerEnv first, then remoteEnv (remoteEnv overrides on key collision)
|
||||
let mut merged_env: HashMap<&String, &String> = HashMap::new();
|
||||
if let Some(env) = container_env {
|
||||
for (k, v) in env {
|
||||
merged_env.insert(k, v);
|
||||
}
|
||||
}
|
||||
if let Some(env) = remote_env {
|
||||
for (k, v) in env {
|
||||
merged_env.insert(k, v);
|
||||
}
|
||||
}
|
||||
if !merged_env.is_empty() {
|
||||
let mut keys: Vec<&&String> = merged_env.keys().collect();
|
||||
keys.sort();
|
||||
let env_lines: Vec<String> = keys
|
||||
.iter()
|
||||
.map(|k| format!("ENV {}={}", k, merged_env[*k]))
|
||||
.collect();
|
||||
sections.push(env_lines.join("\n"));
|
||||
}
|
||||
|
||||
if let Some(user) = remote_user {
|
||||
sections.push(format!("USER {}", user));
|
||||
|
|
@ -53,7 +64,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn base_image_only() {
|
||||
let result = generate("FROM ubuntu:22.04", &[], &None, None);
|
||||
let result = generate("FROM ubuntu:22.04", &[], &None, &None, None);
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\nFROM ubuntu:22.04\n"
|
||||
|
|
@ -63,7 +74,7 @@ mod tests {
|
|||
#[test]
|
||||
fn base_dockerfile_preserved_as_is() {
|
||||
let base = "FROM ubuntu:22.04\nRUN apt-get update\nRUN apt-get install -y curl";
|
||||
let result = generate(base, &[], &None, None);
|
||||
let result = generate(base, &[], &None, &None, None);
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\nFROM ubuntu:22.04\nRUN apt-get update\nRUN apt-get install -y curl\n"
|
||||
|
|
@ -76,7 +87,7 @@ mod tests {
|
|||
make_layer("node", "node-1", "RUN install-node.sh"),
|
||||
make_layer("python", "python-1", "RUN install-python.sh"),
|
||||
];
|
||||
let result = generate("FROM ubuntu:22.04", &layers, &None, None);
|
||||
let result = generate("FROM ubuntu:22.04", &layers, &None, &None, None);
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\n\
|
||||
|
|
@ -92,7 +103,7 @@ mod tests {
|
|||
env.insert("ZEBRA".to_string(), "stripes".to_string());
|
||||
env.insert("APPLE".to_string(), "red".to_string());
|
||||
env.insert("MANGO".to_string(), "yellow".to_string());
|
||||
let result = generate("FROM alpine", &[], &Some(env), None);
|
||||
let result = generate("FROM alpine", &[], &None, &Some(env), None);
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\n\
|
||||
|
|
@ -105,7 +116,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn with_remote_user() {
|
||||
let result = generate("FROM alpine", &[], &None, Some("vscode"));
|
||||
let result = generate("FROM alpine", &[], &None, &None, Some("vscode"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\n\
|
||||
|
|
@ -120,7 +131,7 @@ mod tests {
|
|||
let mut env = HashMap::new();
|
||||
env.insert("PATH".to_string(), "/usr/local/bin".to_string());
|
||||
env.insert("HOME".to_string(), "/home/vscode".to_string());
|
||||
let result = generate("FROM ubuntu:22.04", &layers, &Some(env), Some("vscode"));
|
||||
let result = generate("FROM ubuntu:22.04", &layers, &None, &Some(env), Some("vscode"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\n\
|
||||
|
|
@ -134,7 +145,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn empty_feature_layers_no_extra_blank_lines() {
|
||||
let result = generate("FROM alpine", &[], &None, Some("dev"));
|
||||
let result = generate("FROM alpine", &[], &None, &None, Some("dev"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\n\
|
||||
|
|
@ -146,7 +157,7 @@ mod tests {
|
|||
#[test]
|
||||
fn empty_env_map_treated_as_none() {
|
||||
let env = HashMap::new();
|
||||
let result = generate("FROM alpine", &[], &Some(env), None);
|
||||
let result = generate("FROM alpine", &[], &None, &Some(env), None);
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\nFROM alpine\n"
|
||||
|
|
@ -162,10 +173,38 @@ mod tests {
|
|||
\n\
|
||||
FROM ubuntu:22.04\n\
|
||||
COPY --from=builder /app/bin /usr/local/bin";
|
||||
let result = generate(base, &[], &None, None);
|
||||
let result = generate(base, &[], &None, &None, None);
|
||||
assert!(result.starts_with("# Generated by arc-devcontainer\n\n"));
|
||||
assert!(result.contains("FROM ubuntu:22.04 AS builder"));
|
||||
assert!(result.contains("COPY --from=builder /app/bin /usr/local/bin"));
|
||||
assert!(result.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_env_only() {
|
||||
let mut cenv = HashMap::new();
|
||||
cenv.insert("DEBIAN_FRONTEND".to_string(), "noninteractive".to_string());
|
||||
let result = generate("FROM alpine", &[], &Some(cenv), &None, None);
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\n\
|
||||
FROM alpine\n\n\
|
||||
ENV DEBIAN_FRONTEND=noninteractive\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_env_and_remote_env_merged() {
|
||||
let mut cenv = HashMap::new();
|
||||
cenv.insert("SHARED".to_string(), "from_container".to_string());
|
||||
cenv.insert("ONLY_CONTAINER".to_string(), "yes".to_string());
|
||||
let mut renv = HashMap::new();
|
||||
renv.insert("SHARED".to_string(), "from_remote".to_string());
|
||||
renv.insert("ONLY_REMOTE".to_string(), "yes".to_string());
|
||||
let result = generate("FROM alpine", &[], &Some(cenv), &Some(renv), None);
|
||||
// remoteEnv overrides containerEnv on collision
|
||||
assert!(result.contains("ENV SHARED=from_remote"));
|
||||
assert!(result.contains("ENV ONLY_CONTAINER=yes"));
|
||||
assert!(result.contains("ENV ONLY_REMOTE=yes"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,21 +25,27 @@ pub struct DevcontainerConfig {
|
|||
pub dockerfile: String,
|
||||
/// Directory for docker build context
|
||||
pub build_context: PathBuf,
|
||||
/// Build arguments (docker build --build-arg)
|
||||
pub build_args: HashMap<String, String>,
|
||||
/// Run on host before build
|
||||
pub initialize_commands: Vec<Command>,
|
||||
/// Run in container after first creation (before updateContentCommand)
|
||||
pub on_create_commands: Vec<Command>,
|
||||
/// Run in container after creation
|
||||
pub post_create_commands: Vec<Command>,
|
||||
/// Run in container on each start
|
||||
pub post_start_commands: Vec<Command>,
|
||||
/// remoteEnv merged
|
||||
pub environment: HashMap<String, String>,
|
||||
/// containerEnv — baked into Dockerfile as ENV directives
|
||||
pub container_env: HashMap<String, String>,
|
||||
pub remote_user: Option<String>,
|
||||
/// default: /workspaces/{repo-name}
|
||||
pub workspace_folder: String,
|
||||
/// first = default preview port
|
||||
pub forwarded_ports: Vec<u16>,
|
||||
/// if dockerComposeFile mode
|
||||
pub compose_file: Option<PathBuf>,
|
||||
/// Compose file paths (empty if not in compose mode)
|
||||
pub compose_files: Vec<PathBuf>,
|
||||
pub compose_service: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +119,11 @@ impl DevcontainerResolver {
|
|||
|
||||
// Handle compose mode
|
||||
if let Some(compose_ref) = &devcontainer.docker_compose_file {
|
||||
let compose_path = base_dir.join(variables::substitute(compose_ref, &vars));
|
||||
let compose_paths: Vec<PathBuf> = compose_ref
|
||||
.paths()
|
||||
.iter()
|
||||
.map(|p| base_dir.join(variables::substitute(p, &vars)))
|
||||
.collect();
|
||||
let service_name = devcontainer
|
||||
.service
|
||||
.as_ref()
|
||||
|
|
@ -125,8 +135,8 @@ impl DevcontainerResolver {
|
|||
.clone();
|
||||
|
||||
let compose_config =
|
||||
compose::parse_compose(&compose_path, &service_name).map_err(|e| {
|
||||
DevcontainerError::Compose(format!("{}: {e}", compose_path.display()))
|
||||
compose::parse_compose_multi(&compose_paths, &service_name).map_err(|e| {
|
||||
DevcontainerError::Compose(e)
|
||||
})?;
|
||||
|
||||
let mut environment = HashMap::new();
|
||||
|
|
@ -139,10 +149,14 @@ impl DevcontainerResolver {
|
|||
}
|
||||
}
|
||||
|
||||
// Use the first compose file's parent as build context base
|
||||
let compose_base_dir = compose_paths
|
||||
.first()
|
||||
.and_then(|p| p.parent())
|
||||
.unwrap_or(base_dir);
|
||||
|
||||
let dockerfile = if let Some(build) = &compose_config.build {
|
||||
let df_path = compose_path
|
||||
.parent()
|
||||
.unwrap_or(base_dir)
|
||||
let df_path = compose_base_dir
|
||||
.join(&build.context)
|
||||
.join(build.dockerfile.as_deref().unwrap_or("Dockerfile"));
|
||||
std::fs::read_to_string(&df_path).map_err(|source| {
|
||||
|
|
@ -157,11 +171,13 @@ impl DevcontainerResolver {
|
|||
|
||||
return Ok(DevcontainerConfig {
|
||||
dockerfile,
|
||||
build_context: compose_path
|
||||
.parent()
|
||||
.unwrap_or(base_dir)
|
||||
.to_path_buf(),
|
||||
build_context: compose_base_dir.to_path_buf(),
|
||||
build_args: HashMap::new(),
|
||||
initialize_commands: Self::collect_commands(&devcontainer.initialize_command, &vars),
|
||||
on_create_commands: Self::collect_commands(
|
||||
&devcontainer.on_create_command,
|
||||
&vars,
|
||||
),
|
||||
post_create_commands: Self::collect_commands(
|
||||
&devcontainer.post_create_command,
|
||||
&vars,
|
||||
|
|
@ -171,19 +187,22 @@ impl DevcontainerResolver {
|
|||
&vars,
|
||||
),
|
||||
environment,
|
||||
container_env: Self::collect_container_env(&devcontainer.container_env, &vars),
|
||||
remote_user: devcontainer
|
||||
.remote_user
|
||||
.clone()
|
||||
.or(compose_config.user),
|
||||
workspace_folder,
|
||||
forwarded_ports: compose_config.ports,
|
||||
compose_file: Some(compose_path),
|
||||
compose_files: compose_paths,
|
||||
compose_service: Some(service_name),
|
||||
});
|
||||
}
|
||||
|
||||
// Image or Dockerfile mode
|
||||
let (base_dockerfile, build_context) = if let Some(build) = &devcontainer.build {
|
||||
let (base_dockerfile, build_context, build_args) = if let Some(build) =
|
||||
&devcontainer.build
|
||||
{
|
||||
let context_dir = build
|
||||
.context
|
||||
.as_ref()
|
||||
|
|
@ -193,20 +212,24 @@ impl DevcontainerResolver {
|
|||
build.dockerfile.as_deref().unwrap_or("Dockerfile"),
|
||||
&vars,
|
||||
));
|
||||
let content =
|
||||
std::fs::read_to_string(&df_path).map_err(|source| {
|
||||
DevcontainerError::ReadFile {
|
||||
path: df_path,
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
(content, context_dir)
|
||||
let content = std::fs::read_to_string(&df_path).map_err(|source| {
|
||||
DevcontainerError::ReadFile {
|
||||
path: df_path,
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let args: HashMap<String, String> = build
|
||||
.args
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), variables::substitute(v, &vars)))
|
||||
.collect();
|
||||
(content, context_dir, args)
|
||||
} else {
|
||||
let image = devcontainer
|
||||
.image
|
||||
.as_deref()
|
||||
.unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu");
|
||||
(format!("FROM {image}"), base_dir.to_path_buf())
|
||||
(format!("FROM {image}"), base_dir.to_path_buf(), HashMap::new())
|
||||
};
|
||||
|
||||
// Features
|
||||
|
|
@ -220,6 +243,7 @@ impl DevcontainerResolver {
|
|||
let dockerfile_content = dockerfile::generate(
|
||||
&base_dockerfile,
|
||||
&feature_layers,
|
||||
&devcontainer.container_env,
|
||||
&devcontainer.remote_env,
|
||||
devcontainer.remote_user.as_deref(),
|
||||
);
|
||||
|
|
@ -243,14 +267,17 @@ impl DevcontainerResolver {
|
|||
Ok(DevcontainerConfig {
|
||||
dockerfile: dockerfile_content,
|
||||
build_context,
|
||||
build_args,
|
||||
initialize_commands: Self::collect_commands(&devcontainer.initialize_command, &vars),
|
||||
on_create_commands: Self::collect_commands(&devcontainer.on_create_command, &vars),
|
||||
post_create_commands: Self::collect_commands(&devcontainer.post_create_command, &vars),
|
||||
post_start_commands: Self::collect_commands(&devcontainer.post_start_command, &vars),
|
||||
environment,
|
||||
container_env: Self::collect_container_env(&devcontainer.container_env, &vars),
|
||||
remote_user: devcontainer.remote_user.clone(),
|
||||
workspace_folder,
|
||||
forwarded_ports,
|
||||
compose_file: None,
|
||||
compose_files: Vec::new(),
|
||||
compose_service: None,
|
||||
})
|
||||
}
|
||||
|
|
@ -309,6 +336,19 @@ impl DevcontainerResolver {
|
|||
original_path
|
||||
}
|
||||
|
||||
fn collect_container_env(
|
||||
env: &Option<HashMap<String, String>>,
|
||||
vars: &variables::VariableContext,
|
||||
) -> HashMap<String, String> {
|
||||
match env {
|
||||
None => HashMap::new(),
|
||||
Some(map) => map
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), variables::substitute(v, vars)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_commands(
|
||||
cmd: &Option<types::LifecycleCommand>,
|
||||
vars: &variables::VariableContext,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ pub struct DevcontainerJson {
|
|||
/// Dockerfile build config
|
||||
pub build: Option<BuildConfig>,
|
||||
|
||||
/// Docker Compose file path (compose mode)
|
||||
pub docker_compose_file: Option<String>,
|
||||
/// Docker Compose file path(s) (compose mode)
|
||||
pub docker_compose_file: Option<ComposeFileRef>,
|
||||
|
||||
/// Service name for compose mode
|
||||
pub service: Option<String>,
|
||||
|
|
@ -48,7 +48,10 @@ pub struct DevcontainerJson {
|
|||
/// Run on host before anything else
|
||||
pub initialize_command: Option<LifecycleCommand>,
|
||||
|
||||
/// Run in container after first creation
|
||||
/// Run in container after first creation (before updateContentCommand)
|
||||
pub on_create_command: Option<LifecycleCommand>,
|
||||
|
||||
/// Run in container after creation
|
||||
pub post_create_command: Option<LifecycleCommand>,
|
||||
|
||||
/// Run in container on every start
|
||||
|
|
@ -72,6 +75,23 @@ pub struct BuildConfig {
|
|||
pub args: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// A reference to one or more Docker Compose files.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ComposeFileRef {
|
||||
Single(String),
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
impl ComposeFileRef {
|
||||
pub fn paths(&self) -> Vec<&str> {
|
||||
match self {
|
||||
Self::Single(s) => vec![s.as_str()],
|
||||
Self::Multiple(v) => v.iter().map(String::as_str).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A lifecycle command can be a string, array of strings, or object of named commands.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
|
|
@ -188,13 +208,26 @@ mod tests {
|
|||
}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(
|
||||
config.docker_compose_file.as_deref(),
|
||||
Some("docker-compose.yml")
|
||||
config.docker_compose_file.as_ref().unwrap().paths(),
|
||||
vec!["docker-compose.yml"]
|
||||
);
|
||||
assert_eq!(config.service.as_deref(), Some("app"));
|
||||
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_compose_mode_array() {
|
||||
let json = r#"{
|
||||
"dockerComposeFile": ["docker-compose.yml", "docker-compose.override.yml"],
|
||||
"service": "app"
|
||||
}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(
|
||||
config.docker_compose_file.as_ref().unwrap().paths(),
|
||||
vec!["docker-compose.yml", "docker-compose.override.yml"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_ignored() {
|
||||
let json = r#"{"image": "ubuntu", "unknownField": true, "customizations": {}}"#;
|
||||
|
|
|
|||
240
crates/arc-devcontainer/tests/e2e.rs
Normal file
240
crates/arc-devcontainer/tests/e2e.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
//! End-to-end tests exercising full resolver pipeline with realistic devcontainer configs.
|
||||
//! These tests verify the 4 critical gaps are wired correctly through the entire stack:
|
||||
//! 1. onCreateCommand
|
||||
//! 2. build.args
|
||||
//! 3. containerEnv
|
||||
//! 4. dockerComposeFile array
|
||||
|
||||
use arc_devcontainer::{Command, DevcontainerResolver};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn fixture_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
/// Realistic Python project: Dockerfile + build.args + containerEnv + onCreateCommand + remoteEnv
|
||||
/// Verifies all 4 gaps work together in a single config.
|
||||
#[tokio::test]
|
||||
async fn realistic_python_project() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("realistic-python"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Gap 2: build.args exposed for docker build --build-arg
|
||||
assert_eq!(
|
||||
config.build_args.get("PYTHON_VERSION").map(String::as_str),
|
||||
Some("3.12")
|
||||
);
|
||||
|
||||
// Gap 3: containerEnv baked into Dockerfile as ENV directives
|
||||
assert!(config.dockerfile.contains("ENV PIP_NO_CACHE_DIR=1"));
|
||||
assert!(config.dockerfile.contains("ENV PYTHONDONTWRITEBYTECODE=1"));
|
||||
assert_eq!(
|
||||
config.container_env.get("PIP_NO_CACHE_DIR").map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
|
||||
// Gap 3: remoteEnv overrides containerEnv on key collision (PYTHONUNBUFFERED)
|
||||
// In the Dockerfile, remoteEnv value "yes" should win over containerEnv value "1"
|
||||
assert!(config.dockerfile.contains("ENV PYTHONUNBUFFERED=yes"));
|
||||
// environment HashMap gets the remoteEnv value
|
||||
assert_eq!(
|
||||
config.environment.get("PYTHONUNBUFFERED").map(String::as_str),
|
||||
Some("yes")
|
||||
);
|
||||
|
||||
// Gap 3: remoteEnv with variable substitution
|
||||
assert_eq!(
|
||||
config.environment.get("PYTHONPATH").map(String::as_str),
|
||||
Some("/workspaces/realistic-python/src")
|
||||
);
|
||||
|
||||
// Gap 1: onCreateCommand parsed and exposed
|
||||
assert_eq!(config.on_create_commands.len(), 1);
|
||||
assert!(
|
||||
matches!(&config.on_create_commands[0], Command::Shell(s) if s == "pip install -r requirements.txt")
|
||||
);
|
||||
|
||||
// Other lifecycle commands still work
|
||||
assert_eq!(config.post_create_commands.len(), 1);
|
||||
assert!(
|
||||
matches!(&config.post_create_commands[0], Command::Shell(s) if s == "python manage.py migrate")
|
||||
);
|
||||
assert_eq!(config.post_start_commands.len(), 1);
|
||||
assert!(
|
||||
matches!(&config.post_start_commands[0], Command::Shell(s) if s == "python manage.py runserver 0.0.0.0:8000")
|
||||
);
|
||||
|
||||
// Dockerfile content is the actual file (not generated FROM line)
|
||||
assert!(config.dockerfile.contains("ARG PYTHON_VERSION=3.11"));
|
||||
assert!(config.dockerfile.contains("apt-get update"));
|
||||
|
||||
// Standard fields
|
||||
assert_eq!(config.remote_user.as_deref(), Some("developer"));
|
||||
assert_eq!(config.forwarded_ports, vec![8000, 5432]);
|
||||
assert!(config.compose_files.is_empty());
|
||||
}
|
||||
|
||||
/// Realistic compose project: multi-file compose + containerEnv + onCreateCommand + remoteEnv
|
||||
/// Verifies gaps 1, 3, 4 work together in compose mode.
|
||||
#[tokio::test]
|
||||
async fn realistic_compose_project() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("realistic-compose"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Gap 4: multiple compose files resolved
|
||||
assert_eq!(config.compose_files.len(), 2);
|
||||
assert_eq!(config.compose_service.as_deref(), Some("app"));
|
||||
|
||||
// Gap 4: image from base compose file (override doesn't change image)
|
||||
assert!(config.dockerfile.contains("FROM node:20-bookworm"));
|
||||
|
||||
// Gap 4: ports merged from both files (base: 3000, 9229; override: 4000)
|
||||
assert!(config.forwarded_ports.contains(&3000));
|
||||
assert!(config.forwarded_ports.contains(&9229));
|
||||
assert!(config.forwarded_ports.contains(&4000));
|
||||
assert_eq!(config.forwarded_ports.len(), 3);
|
||||
|
||||
// Gap 4: environment merged from both compose files + remoteEnv
|
||||
assert_eq!(
|
||||
config.environment.get("NODE_ENV").map(String::as_str),
|
||||
Some("development")
|
||||
);
|
||||
assert_eq!(
|
||||
config.environment.get("DEBUG").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
config.environment.get("LOG_LEVEL").map(String::as_str),
|
||||
Some("verbose")
|
||||
);
|
||||
// remoteEnv values
|
||||
assert_eq!(
|
||||
config.environment.get("DATABASE_URL").map(String::as_str),
|
||||
Some("postgres://postgres:devpass@db:5432/myapp_dev")
|
||||
);
|
||||
assert_eq!(
|
||||
config.environment.get("REDIS_URL").map(String::as_str),
|
||||
Some("redis://redis:6379")
|
||||
);
|
||||
|
||||
// Gap 3: containerEnv exposed on config
|
||||
assert_eq!(
|
||||
config.container_env.get("TERM").map(String::as_str),
|
||||
Some("xterm-256color")
|
||||
);
|
||||
assert_eq!(
|
||||
config.container_env.get("EDITOR").map(String::as_str),
|
||||
Some("vim")
|
||||
);
|
||||
|
||||
// Gap 1: onCreateCommand in compose mode
|
||||
assert_eq!(config.on_create_commands.len(), 1);
|
||||
assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == "npm ci"));
|
||||
|
||||
// Other lifecycle commands
|
||||
assert_eq!(config.post_create_commands.len(), 1);
|
||||
assert!(
|
||||
matches!(&config.post_create_commands[0], Command::Shell(s) if s == "npm run db:migrate")
|
||||
);
|
||||
assert_eq!(config.post_start_commands.len(), 1);
|
||||
assert!(matches!(&config.post_start_commands[0], Command::Shell(s) if s == "npm run dev"));
|
||||
|
||||
// User comes from compose (node) but remoteUser also set to node
|
||||
assert_eq!(config.remote_user.as_deref(), Some("node"));
|
||||
assert_eq!(config.workspace_folder, "/workspace");
|
||||
}
|
||||
|
||||
/// All lifecycle commands in different forms: string, array, object, and the new onCreateCommand.
|
||||
#[tokio::test]
|
||||
async fn all_lifecycle_command_forms() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("all-lifecycle"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// initializeCommand as string
|
||||
assert_eq!(config.initialize_commands.len(), 1);
|
||||
assert!(matches!(&config.initialize_commands[0], Command::Shell(s) if s == "echo pre-build"));
|
||||
|
||||
// Gap 1: onCreateCommand as array
|
||||
assert_eq!(config.on_create_commands.len(), 1);
|
||||
assert!(matches!(&config.on_create_commands[0], Command::Args(args) if args == &["make", "setup"]));
|
||||
|
||||
// postCreateCommand as object (parallel)
|
||||
assert_eq!(config.post_create_commands.len(), 1);
|
||||
assert!(matches!(&config.post_create_commands[0], Command::Parallel(map) if map.len() == 2));
|
||||
|
||||
// postStartCommand as string
|
||||
assert_eq!(config.post_start_commands.len(), 1);
|
||||
assert!(matches!(&config.post_start_commands[0], Command::Shell(s) if s == "echo started"));
|
||||
}
|
||||
|
||||
/// Verify containerEnv doesn't pollute the environment HashMap (which is remoteEnv only).
|
||||
#[tokio::test]
|
||||
async fn container_env_separate_from_environment() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("realistic-python"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// container_env has containerEnv values
|
||||
assert!(config.container_env.contains_key("PYTHONDONTWRITEBYTECODE"));
|
||||
assert!(config.container_env.contains_key("PIP_NO_CACHE_DIR"));
|
||||
|
||||
// environment only has remoteEnv values (not containerEnv-only keys)
|
||||
assert!(!config.environment.contains_key("PYTHONDONTWRITEBYTECODE"));
|
||||
assert!(!config.environment.contains_key("PIP_NO_CACHE_DIR"));
|
||||
// PYTHONUNBUFFERED is in both - environment gets remoteEnv value
|
||||
assert_eq!(
|
||||
config.environment.get("PYTHONUNBUFFERED").map(String::as_str),
|
||||
Some("yes")
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify build_args default to empty in non-dockerfile modes.
|
||||
#[tokio::test]
|
||||
async fn build_args_empty_in_image_and_compose_modes() {
|
||||
let image_config = DevcontainerResolver::resolve(&fixture_path("image-only"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(image_config.build_args.is_empty());
|
||||
|
||||
let compose_config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(compose_config.build_args.is_empty());
|
||||
}
|
||||
|
||||
/// Verify compose_files is empty for non-compose modes.
|
||||
#[tokio::test]
|
||||
async fn compose_files_empty_in_non_compose_modes() {
|
||||
let image_config = DevcontainerResolver::resolve(&fixture_path("image-only"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(image_config.compose_files.is_empty());
|
||||
|
||||
let df_config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(df_config.compose_files.is_empty());
|
||||
}
|
||||
|
||||
/// Verify on_create_commands defaults to empty when not specified.
|
||||
#[tokio::test]
|
||||
async fn on_create_commands_empty_when_not_specified() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("variables"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(config.on_create_commands.is_empty());
|
||||
}
|
||||
|
||||
/// Verify container_env defaults to empty when not specified.
|
||||
#[tokio::test]
|
||||
async fn container_env_empty_when_not_specified() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("variables"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(config.container_env.is_empty());
|
||||
}
|
||||
10
crates/arc-devcontainer/tests/fixtures/all-lifecycle/.devcontainer/devcontainer.json
vendored
Normal file
10
crates/arc-devcontainer/tests/fixtures/all-lifecycle/.devcontainer/devcontainer.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"image": "ubuntu:22.04",
|
||||
"initializeCommand": "echo pre-build",
|
||||
"onCreateCommand": ["make", "setup"],
|
||||
"postCreateCommand": {
|
||||
"install": "npm install",
|
||||
"build": "npm run build"
|
||||
},
|
||||
"postStartCommand": "echo started"
|
||||
}
|
||||
5
crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/base.yml
vendored
Normal file
5
crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/base.yml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
services:
|
||||
app:
|
||||
image: node:20
|
||||
ports:
|
||||
- "3000:3000"
|
||||
5
crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/devcontainer.json
vendored
Normal file
5
crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/devcontainer.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"dockerComposeFile": ["base.yml", "override.yml"],
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace"
|
||||
}
|
||||
5
crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/override.yml
vendored
Normal file
5
crates/arc-devcontainer/tests/fixtures/compose-multi/.devcontainer/override.yml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
services:
|
||||
app:
|
||||
image: node:22
|
||||
environment:
|
||||
- "OVERRIDE_VAR=true"
|
||||
|
|
@ -2,7 +2,8 @@
|
|||
// This is a JSONC file with comments
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": ".."
|
||||
"context": "..",
|
||||
"args": {"NODE_VERSION": "20"}
|
||||
},
|
||||
"remoteUser": "developer",
|
||||
"postCreateCommand": "npm install",
|
||||
|
|
|
|||
|
|
@ -5,5 +5,9 @@
|
|||
"remoteEnv": {
|
||||
"EDITOR": "code"
|
||||
},
|
||||
"containerEnv": {
|
||||
"DEBIAN_FRONTEND": "noninteractive"
|
||||
},
|
||||
"onCreateCommand": "setup.sh",
|
||||
"postCreateCommand": "echo hello"
|
||||
}
|
||||
|
|
|
|||
17
crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json
vendored
Normal file
17
crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"dockerComposeFile": ["docker-compose.yml", "docker-compose.override.yml"],
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"remoteUser": "node",
|
||||
"containerEnv": {
|
||||
"TERM": "xterm-256color",
|
||||
"EDITOR": "vim"
|
||||
},
|
||||
"remoteEnv": {
|
||||
"DATABASE_URL": "postgres://postgres:devpass@db:5432/myapp_dev",
|
||||
"REDIS_URL": "redis://redis:6379"
|
||||
},
|
||||
"onCreateCommand": "npm ci",
|
||||
"postCreateCommand": "npm run db:migrate",
|
||||
"postStartCommand": "npm run dev"
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
services:
|
||||
app:
|
||||
environment:
|
||||
- "DEBUG=true"
|
||||
- "LOG_LEVEL=verbose"
|
||||
ports:
|
||||
- "4000:4000"
|
||||
22
crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.yml
vendored
Normal file
22
crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/docker-compose.yml
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
services:
|
||||
app:
|
||||
image: node:20-bookworm
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "9229:9229"
|
||||
environment:
|
||||
- "NODE_ENV=development"
|
||||
user: "node"
|
||||
volumes:
|
||||
- ..:/workspace:cached
|
||||
db:
|
||||
image: postgres:16
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_PASSWORD: devpass
|
||||
POSTGRES_DB: myapp_dev
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
10
crates/arc-devcontainer/tests/fixtures/realistic-python/.devcontainer/Dockerfile
vendored
Normal file
10
crates/arc-devcontainer/tests/fixtures/realistic-python/.devcontainer/Dockerfile
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
ARG PYTHON_VERSION=3.11
|
||||
FROM python:${PYTHON_VERSION}-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -s /bin/bash developer
|
||||
WORKDIR /workspaces/app
|
||||
23
crates/arc-devcontainer/tests/fixtures/realistic-python/.devcontainer/devcontainer.json
vendored
Normal file
23
crates/arc-devcontainer/tests/fixtures/realistic-python/.devcontainer/devcontainer.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
// Realistic Python project devcontainer
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
"PYTHON_VERSION": "3.12"
|
||||
}
|
||||
},
|
||||
"containerEnv": {
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"PIP_NO_CACHE_DIR": "1"
|
||||
},
|
||||
"remoteEnv": {
|
||||
"PYTHONPATH": "${containerWorkspaceFolder}/src",
|
||||
"PYTHONUNBUFFERED": "yes"
|
||||
},
|
||||
"remoteUser": "developer",
|
||||
"forwardPorts": [8000, 5432],
|
||||
"onCreateCommand": "pip install -r requirements.txt",
|
||||
"postCreateCommand": "python manage.py migrate",
|
||||
"postStartCommand": "python manage.py runserver 0.0.0.0:8000",
|
||||
}
|
||||
|
|
@ -18,11 +18,22 @@ async fn resolve_image_only() {
|
|||
assert_eq!(config.forwarded_ports, vec![3000, 8080]);
|
||||
assert_eq!(config.environment.get("EDITOR").map(String::as_str), Some("code"));
|
||||
assert_eq!(config.workspace_folder, "/workspaces/image-only");
|
||||
assert!(config.compose_file.is_none());
|
||||
assert!(config.compose_files.is_empty());
|
||||
assert!(config.compose_service.is_none());
|
||||
|
||||
assert_eq!(config.post_create_commands.len(), 1);
|
||||
assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "echo hello"));
|
||||
|
||||
// onCreateCommand
|
||||
assert_eq!(config.on_create_commands.len(), 1);
|
||||
assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == "setup.sh"));
|
||||
|
||||
// containerEnv baked into Dockerfile
|
||||
assert!(config.dockerfile.contains("ENV DEBIAN_FRONTEND=noninteractive"));
|
||||
assert_eq!(
|
||||
config.container_env.get("DEBIAN_FRONTEND").map(String::as_str),
|
||||
Some("noninteractive")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -39,6 +50,9 @@ async fn resolve_dockerfile_mode() {
|
|||
|
||||
assert_eq!(config.post_create_commands.len(), 1);
|
||||
assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "npm install"));
|
||||
|
||||
// build.args
|
||||
assert_eq!(config.build_args.get("NODE_VERSION").map(String::as_str), Some("20"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -51,7 +65,7 @@ async fn resolve_compose_mode() {
|
|||
assert!(config.dockerfile.contains("FROM node:20"));
|
||||
assert_eq!(config.workspace_folder, "/workspace");
|
||||
assert_eq!(config.remote_user.as_deref(), Some("node"));
|
||||
assert!(config.compose_file.is_some());
|
||||
assert_eq!(config.compose_files.len(), 1);
|
||||
assert_eq!(config.compose_service.as_deref(), Some("app"));
|
||||
|
||||
// Ports come from compose + remoteEnv merged
|
||||
|
|
@ -79,6 +93,28 @@ async fn resolve_variables() {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_compose_multi() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("compose-multi"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Override file wins for image
|
||||
assert!(config.dockerfile.contains("FROM node:22"));
|
||||
assert_eq!(config.workspace_folder, "/workspace");
|
||||
assert_eq!(config.compose_files.len(), 2);
|
||||
assert_eq!(config.compose_service.as_deref(), Some("app"));
|
||||
|
||||
// Port from base.yml
|
||||
assert_eq!(config.forwarded_ports, vec![3000]);
|
||||
|
||||
// Environment from override.yml
|
||||
assert_eq!(
|
||||
config.environment.get("OVERRIDE_VAR").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_not_found() {
|
||||
let result = DevcontainerResolver::resolve(&fixture_path("nonexistent")).await;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue