mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Fix 4 spec compliance gaps in arc-devcontainer
- Stop baking remoteEnv into Dockerfile (only containerEnv belongs as ENV) - Merge forwardPorts with compose ports in compose mode (with dedup) - Support build.target (parsed with variable substitution) - Handle forwardPorts string formats like "8080:80" and "9090" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3b2c0de11a
commit
4ad60489b9
11 changed files with 176 additions and 74 deletions
|
|
@ -8,17 +8,17 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https
|
|||
|
||||
| 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 |
|
||||
| `name` | No | Silently ignored by serde (unknown fields are skipped); not exposed in `DevcontainerConfig` |
|
||||
| `forwardPorts` | Yes | Numeric and string formats (e.g., `"8080:80"`, `"9090"`) extracted into `DevcontainerConfig::forwarded_ports`; merged with compose ports in compose mode |
|
||||
| `portsAttributes` | No | Not parsed |
|
||||
| `otherPortsAttributes` | No | Not parsed |
|
||||
| `updateRemoteUserUID` | No | Not parsed |
|
||||
| `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` |
|
||||
| `containerUser` | No | Parsed but unused; 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 |
|
||||
| `overrideCommand` | No | Parsed but unused |
|
||||
| `shutdownAction` | No | Not parsed |
|
||||
|
||||
## Image
|
||||
|
|
@ -34,7 +34,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` | Yes | Parsed and exposed in `DevcontainerConfig::build_args` for passing to `docker build --build-arg` |
|
||||
| `build.target` | No | Not parsed |
|
||||
| `build.target` | Yes | Parsed with variable substitution; exposed as `DevcontainerConfig::build_target` for passing to `docker build --target` |
|
||||
| `build.cacheFrom` | No | Not parsed |
|
||||
| `build.options` | No | Not parsed |
|
||||
|
||||
|
|
@ -46,9 +46,9 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https
|
|||
| `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 |
|
||||
| `overrideCommand` | No | Parsed but unused |
|
||||
| `workspaceFolder` | Yes | Defaults to `/workspaces/{repo-name}` |
|
||||
| `workspaceMount` | Partial | Parsed in `DevcontainerJson::workspace_mount` but not used |
|
||||
| `workspaceMount` | No | Parsed but unused |
|
||||
|
||||
## Features
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub struct DevcontainerConfig {
|
|||
pub dockerfile: String, // Generated Dockerfile content
|
||||
pub build_context: PathBuf, // Directory for docker build
|
||||
pub build_args: HashMap<String, String>, // docker build --build-arg flags
|
||||
pub build_target: Option<String>, // docker build --target
|
||||
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
|
||||
|
|
@ -36,9 +37,9 @@ pub struct DevcontainerConfig {
|
|||
|
||||
### Environment Variables
|
||||
|
||||
`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).
|
||||
`config.environment` contains the `remoteEnv` values (with variables already substituted). These are runtime-only environment variables, not baked into the Dockerfile. `config.container_env` contains `containerEnv` values (baked into the generated Dockerfile as `ENV` directives).
|
||||
|
||||
- Pass `config.environment` as environment variables when creating the sandbox.
|
||||
- Pass `config.environment` as runtime environment variables when starting the sandbox.
|
||||
- `containerEnv` values are already in the Dockerfile; `config.container_env` is available for reference.
|
||||
|
||||
### Workspace Folder
|
||||
|
|
@ -187,4 +188,4 @@ async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result<Sandbox> {
|
|||
- **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.
|
||||
- **Port forwarding**: Both numeric and string port formats (e.g., `"8080:80"`, `"9090"`) are supported in `forwardPorts`. In compose mode, `forwardPorts` are merged with compose service ports.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ 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 {
|
||||
let mut sections: Vec<String> = Vec::new();
|
||||
|
|
@ -19,27 +18,17 @@ pub fn generate(
|
|||
sections.push(layer.dockerfile_snippet.clone());
|
||||
}
|
||||
|
||||
// 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 !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(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));
|
||||
|
|
@ -64,7 +53,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn base_image_only() {
|
||||
let result = generate("FROM ubuntu:22.04", &[], &None, &None, None);
|
||||
let result = generate("FROM ubuntu:22.04", &[], &None, None);
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\nFROM ubuntu:22.04\n"
|
||||
|
|
@ -74,7 +63,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, None);
|
||||
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"
|
||||
|
|
@ -87,7 +76,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, None);
|
||||
let result = generate("FROM ubuntu:22.04", &layers, &None, None);
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\n\
|
||||
|
|
@ -103,7 +92,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", &[], &None, &Some(env), None);
|
||||
let result = generate("FROM alpine", &[], &Some(env), None);
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\n\
|
||||
|
|
@ -116,7 +105,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn with_remote_user() {
|
||||
let result = generate("FROM alpine", &[], &None, &None, Some("vscode"));
|
||||
let result = generate("FROM alpine", &[], &None, Some("vscode"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\n\
|
||||
|
|
@ -131,7 +120,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, &None, &Some(env), Some("vscode"));
|
||||
let result = generate("FROM ubuntu:22.04", &layers, &Some(env), Some("vscode"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\n\
|
||||
|
|
@ -145,7 +134,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn empty_feature_layers_no_extra_blank_lines() {
|
||||
let result = generate("FROM alpine", &[], &None, &None, Some("dev"));
|
||||
let result = generate("FROM alpine", &[], &None, Some("dev"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\n\
|
||||
|
|
@ -157,7 +146,7 @@ mod tests {
|
|||
#[test]
|
||||
fn empty_env_map_treated_as_none() {
|
||||
let env = HashMap::new();
|
||||
let result = generate("FROM alpine", &[], &None, &Some(env), None);
|
||||
let result = generate("FROM alpine", &[], &Some(env), None);
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\nFROM alpine\n"
|
||||
|
|
@ -173,7 +162,7 @@ mod tests {
|
|||
\n\
|
||||
FROM ubuntu:22.04\n\
|
||||
COPY --from=builder /app/bin /usr/local/bin";
|
||||
let result = generate(base, &[], &None, &None, None);
|
||||
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"));
|
||||
|
|
@ -184,7 +173,7 @@ mod tests {
|
|||
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);
|
||||
let result = generate("FROM alpine", &[], &Some(cenv), None);
|
||||
assert_eq!(
|
||||
result,
|
||||
"# Generated by arc-devcontainer\n\n\
|
||||
|
|
@ -194,17 +183,14 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn container_env_and_remote_env_merged() {
|
||||
fn container_env_with_multiple_keys() {
|
||||
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"));
|
||||
cenv.insert("ALPHA".to_string(), "first".to_string());
|
||||
cenv.insert("BETA".to_string(), "second".to_string());
|
||||
cenv.insert("GAMMA".to_string(), "third".to_string());
|
||||
let result = generate("FROM alpine", &[], &Some(cenv), None);
|
||||
assert!(result.contains("ENV ALPHA=first"));
|
||||
assert!(result.contains("ENV BETA=second"));
|
||||
assert!(result.contains("ENV GAMMA=third"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ pub struct DevcontainerConfig {
|
|||
pub build_context: PathBuf,
|
||||
/// Build arguments (docker build --build-arg)
|
||||
pub build_args: HashMap<String, String>,
|
||||
/// Multi-stage build target (docker build --target)
|
||||
pub build_target: Option<String>,
|
||||
/// Run on host before build
|
||||
pub initialize_commands: Vec<Command>,
|
||||
/// Run in container after first creation (before updateContentCommand)
|
||||
|
|
@ -173,6 +175,7 @@ impl DevcontainerResolver {
|
|||
dockerfile,
|
||||
build_context: compose_base_dir.to_path_buf(),
|
||||
build_args: HashMap::new(),
|
||||
build_target: None,
|
||||
initialize_commands: Self::collect_commands(&devcontainer.initialize_command, &vars),
|
||||
on_create_commands: Self::collect_commands(
|
||||
&devcontainer.on_create_command,
|
||||
|
|
@ -193,14 +196,22 @@ impl DevcontainerResolver {
|
|||
.clone()
|
||||
.or(compose_config.user),
|
||||
workspace_folder,
|
||||
forwarded_ports: compose_config.ports,
|
||||
forwarded_ports: {
|
||||
let mut ports = compose_config.ports;
|
||||
for port in Self::parse_forward_ports(&devcontainer.forward_ports) {
|
||||
if !ports.contains(&port) {
|
||||
ports.push(port);
|
||||
}
|
||||
}
|
||||
ports
|
||||
},
|
||||
compose_files: compose_paths,
|
||||
compose_service: Some(service_name),
|
||||
});
|
||||
}
|
||||
|
||||
// Image or Dockerfile mode
|
||||
let (base_dockerfile, build_context, build_args) = if let Some(build) =
|
||||
let (base_dockerfile, build_context, build_args, build_target) = if let Some(build) =
|
||||
&devcontainer.build
|
||||
{
|
||||
let context_dir = build
|
||||
|
|
@ -223,13 +234,17 @@ impl DevcontainerResolver {
|
|||
.iter()
|
||||
.map(|(k, v)| (k.clone(), variables::substitute(v, &vars)))
|
||||
.collect();
|
||||
(content, context_dir, args)
|
||||
let target = build
|
||||
.target
|
||||
.as_ref()
|
||||
.map(|t| variables::substitute(t, &vars));
|
||||
(content, context_dir, args, target)
|
||||
} else {
|
||||
let image = devcontainer
|
||||
.image
|
||||
.as_deref()
|
||||
.unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu");
|
||||
(format!("FROM {image}"), base_dir.to_path_buf(), HashMap::new())
|
||||
(format!("FROM {image}"), base_dir.to_path_buf(), HashMap::new(), None)
|
||||
};
|
||||
|
||||
// Features
|
||||
|
|
@ -244,7 +259,6 @@ impl DevcontainerResolver {
|
|||
&base_dockerfile,
|
||||
&feature_layers,
|
||||
&devcontainer.container_env,
|
||||
&devcontainer.remote_env,
|
||||
devcontainer.remote_user.as_deref(),
|
||||
);
|
||||
|
||||
|
|
@ -255,19 +269,13 @@ impl DevcontainerResolver {
|
|||
}
|
||||
}
|
||||
|
||||
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();
|
||||
let forwarded_ports = Self::parse_forward_ports(&devcontainer.forward_ports);
|
||||
|
||||
Ok(DevcontainerConfig {
|
||||
dockerfile: dockerfile_content,
|
||||
build_context,
|
||||
build_args,
|
||||
build_target,
|
||||
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),
|
||||
|
|
@ -374,4 +382,22 @@ impl DevcontainerResolver {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_forward_ports(ports: &[serde_json::Value]) -> Vec<u16> {
|
||||
ports
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
serde_json::Value::Number(n) => n.as_u64().map(|n| n as u16),
|
||||
serde_json::Value::String(s) => {
|
||||
let s = s.split('/').next().unwrap_or(s); // strip protocol
|
||||
if let Some((_host, container)) = s.split_once(':') {
|
||||
container.parse::<u16>().ok()
|
||||
} else {
|
||||
s.parse::<u16>().ok()
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ pub struct BuildConfig {
|
|||
/// Build arguments
|
||||
#[serde(default)]
|
||||
pub args: HashMap<String, String>,
|
||||
|
||||
/// Multi-stage build target
|
||||
pub target: Option<String>,
|
||||
}
|
||||
|
||||
/// A reference to one or more Docker Compose files.
|
||||
|
|
@ -189,7 +192,8 @@ mod tests {
|
|||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": "..",
|
||||
"args": {"VARIANT": "3.9"}
|
||||
"args": {"VARIANT": "3.9"},
|
||||
"target": "dev"
|
||||
}
|
||||
}"#;
|
||||
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
|
||||
|
|
@ -197,6 +201,7 @@ mod tests {
|
|||
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"));
|
||||
assert_eq!(build.target.as_deref(), Some("dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -36,9 +36,8 @@ async fn realistic_python_project() {
|
|||
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"));
|
||||
// After fix: only containerEnv is baked into Dockerfile (remoteEnv is runtime-only)
|
||||
assert!(config.dockerfile.contains("ENV PYTHONUNBUFFERED=1"));
|
||||
// environment HashMap gets the remoteEnv value
|
||||
assert_eq!(
|
||||
config.environment.get("PYTHONUNBUFFERED").map(String::as_str),
|
||||
|
|
@ -92,11 +91,12 @@ async fn realistic_compose_project() {
|
|||
// 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)
|
||||
// Ports merged from both compose files (base: 3000, 9229; override: 4000) + forwardPorts (8080)
|
||||
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);
|
||||
assert!(config.forwarded_ports.contains(&8080));
|
||||
assert_eq!(config.forwarded_ports.len(), 4);
|
||||
|
||||
// Gap 4: environment merged from both compose files + remoteEnv
|
||||
assert_eq!(
|
||||
|
|
@ -207,6 +207,84 @@ async fn build_args_empty_in_image_and_compose_modes() {
|
|||
assert!(compose_config.build_args.is_empty());
|
||||
}
|
||||
|
||||
/// Verify build_target is None for image-only and compose modes.
|
||||
#[tokio::test]
|
||||
async fn build_target_none_in_image_and_compose_modes() {
|
||||
let image_config = DevcontainerResolver::resolve(&fixture_path("image-only"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(image_config.build_target.is_none());
|
||||
|
||||
let compose_config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(compose_config.build_target.is_none());
|
||||
}
|
||||
|
||||
/// Gap 1: remoteEnv values must NOT appear as ENV directives in the generated Dockerfile.
|
||||
/// Only containerEnv should be baked in.
|
||||
#[tokio::test]
|
||||
async fn remote_env_excluded_from_dockerfile() {
|
||||
// image-only fixture has remoteEnv: {"EDITOR": "code"} and containerEnv: {"DEBIAN_FRONTEND": "noninteractive"}
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("image-only"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// containerEnv IS in the Dockerfile
|
||||
assert!(config.dockerfile.contains("ENV DEBIAN_FRONTEND=noninteractive"));
|
||||
|
||||
// remoteEnv is NOT in the Dockerfile
|
||||
assert!(!config.dockerfile.contains("EDITOR=code"));
|
||||
|
||||
// remoteEnv IS in the environment HashMap (runtime-only)
|
||||
assert_eq!(
|
||||
config.environment.get("EDITOR").map(String::as_str),
|
||||
Some("code")
|
||||
);
|
||||
}
|
||||
|
||||
/// Gap 2: forwardPorts in compose mode are merged with compose service ports, with deduplication.
|
||||
#[tokio::test]
|
||||
async fn forward_ports_merged_and_deduped_in_compose() {
|
||||
// compose-mode fixture has compose ports [3000, 9229] and forwardPorts [3000, 5173]
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 3000 appears in both compose ports and forwardPorts — should NOT be duplicated
|
||||
assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]);
|
||||
}
|
||||
|
||||
/// Gap 3: build.target is parsed and exposed in dockerfile mode.
|
||||
#[tokio::test]
|
||||
async fn build_target_in_dockerfile_mode() {
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config.build_target.as_deref(), Some("dev"));
|
||||
}
|
||||
|
||||
/// Gap 4: forwardPorts string formats ("host:container", "port") are parsed correctly.
|
||||
#[tokio::test]
|
||||
async fn forward_ports_string_formats() {
|
||||
// image-only fixture has forwardPorts: [3000, "8080:80", "9090"]
|
||||
let config = DevcontainerResolver::resolve(&fixture_path("image-only"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 3000 is a plain number
|
||||
assert!(config.forwarded_ports.contains(&3000));
|
||||
// "8080:80" extracts container port 80
|
||||
assert!(config.forwarded_ports.contains(&80));
|
||||
// "9090" is parsed as a plain port number
|
||||
assert!(config.forwarded_ports.contains(&9090));
|
||||
// host port 8080 should NOT appear (only container port matters)
|
||||
assert!(!config.forwarded_ports.contains(&8080));
|
||||
|
||||
assert_eq!(config.forwarded_ports, vec![3000, 80, 9090]);
|
||||
}
|
||||
|
||||
/// Verify compose_files is empty for non-compose modes.
|
||||
#[tokio::test]
|
||||
async fn compose_files_empty_in_non_compose_modes() {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"remoteUser": "node",
|
||||
"forwardPorts": [3000, 5173],
|
||||
"postCreateCommand": "npm install",
|
||||
"remoteEnv": {
|
||||
"NODE_ENV": "development"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": "..",
|
||||
"args": {"NODE_VERSION": "20"}
|
||||
"args": {"NODE_VERSION": "20"},
|
||||
"target": "dev"
|
||||
},
|
||||
"remoteUser": "developer",
|
||||
"postCreateCommand": "npm install",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"forwardPorts": [3000, 8080],
|
||||
"forwardPorts": [3000, "8080:80", "9090"],
|
||||
"remoteUser": "vscode",
|
||||
"remoteEnv": {
|
||||
"EDITOR": "code"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"remoteUser": "node",
|
||||
"forwardPorts": [8080],
|
||||
"containerEnv": {
|
||||
"TERM": "xterm-256color",
|
||||
"EDITOR": "vim"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ async fn resolve_image_only() {
|
|||
|
||||
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.forwarded_ports, vec![3000, 80, 9090]);
|
||||
assert_eq!(config.environment.get("EDITOR").map(String::as_str), Some("code"));
|
||||
assert_eq!(config.workspace_folder, "/workspaces/image-only");
|
||||
assert!(config.compose_files.is_empty());
|
||||
|
|
@ -53,6 +53,9 @@ async fn resolve_dockerfile_mode() {
|
|||
|
||||
// build.args
|
||||
assert_eq!(config.build_args.get("NODE_VERSION").map(String::as_str), Some("20"));
|
||||
|
||||
// build.target
|
||||
assert_eq!(config.build_target.as_deref(), Some("dev"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -68,8 +71,8 @@ async fn resolve_compose_mode() {
|
|||
assert_eq!(config.compose_files.len(), 1);
|
||||
assert_eq!(config.compose_service.as_deref(), Some("app"));
|
||||
|
||||
// Ports come from compose + remoteEnv merged
|
||||
assert_eq!(config.forwarded_ports, vec![3000, 9229]);
|
||||
// Ports come from compose + forwardPorts merged
|
||||
assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]);
|
||||
|
||||
// Environment merged from compose + remoteEnv
|
||||
assert_eq!(config.environment.get("NODE_ENV").map(String::as_str), Some("development"));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue