> **Draft / open question for maintainers.** The failure mode this fixes is narrow — a new-Zed-created container exists under the `name`-field project while a CLI-derivation tool (`@devcontainers/cli`, VS Code) operates on the same folder (the container persists in Docker, so the originating Zed session doesn't need to still be open). See issue #54255 failure mode 3 and the fixture's step 6. > > I'd like to pose this as a question rather than a claim: is matching `@devcontainers/cli`'s `getProjectName` precedence something the project wants to take on, given the narrowness of the bug? I wrote this implementation mostly as a way to explore what parity would actually cost — happy to close it if you'd rather leave it as-is, or pare it down (e.g. just rule 4) if a partial match is preferable. > > The broader value beyond this specific bug: devcontainer impls agreeing on the same project name means containers created by Zed, the devcontainer CLI, and VS Code are interchangeable for the same folder, which feels worth it to me — but you know the project's priorities better. > > Folds in #54068 (detection) — closing that PR unmerged; its `MultipleMatchingContainers` error lands here. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #54255 ## Summary **Match `@devcontainers/cli`'s full `getProjectName` precedence.** Replaces `safe_id_lower(devcontainer.json's name)` with the five-step chain the reference CLI walks (see [`src/spec-node/dockerCompose.ts` in devcontainers/cli](https://github.com/devcontainers/cli/blob/main/src/spec-node/dockerCompose.ts)): 1. `COMPOSE_PROJECT_NAME` from the local environment. 2. `COMPOSE_PROJECT_NAME=` in the workspace `.env` file. 3. Top-level `name:` on the merged compose config, when at least one fragment declared it explicitly. 4. `${workspaceFolderBasename}_devcontainer` — only when the first compose file's directory is `<workspace>/.devcontainer/`. 5. Otherwise, the plain basename of the first compose file's directory (no suffix). The old Zed implementation diverged at every one of those inputs: any user setting `COMPOSE_PROJECT_NAME`, shipping a `.env` with one, declaring a top-level compose `name:`, or pointing `dockerComposeFile` outside `.devcontainer/` (e.g. `"../docker-compose.yml"`) got a different project namespace than the CLI and VS Code, producing two compose projects for the same folder. Adds a small `sanitize_compose_project_name()` helper implementing the CLI's rules (lowercase + strip `[^-_a-z0-9]`) — notably preserving hyphens, which `safe_id_lower` would have replaced with underscores. Adds two helpers used by the precedence walk: - `parse_dotenv_compose_project_name` — line scan extracting `COMPOSE_PROJECT_NAME=…` from the workspace `.env`, matching the subset the CLI's regex dotenv reader recognizes. - `compose_fragment_declares_name` — parses each compose fragment with `yaml-rust2` (already a transitive workspace dep; slated to become a direct dep via #53922) and checks for a `name` key on the root mapping (block, quoted, or flow style all work), matching the CLI's own `yaml.load`. `docker compose config` always injects `name: devcontainer` into its merged output when no fragment declared one, so rule 3 needs to distinguish the user-provided case from the injected default — this helper supplies that signal. On YAML parse failure it returns "not declared" (rule 4 applies), matching the CLI's fallback. `project_name()` becomes async and fallible (`async fn project_name(&self) -> Result<String, DevContainerError>`) so it can load the `.env` file and each compose fragment via `self.fs.load`. Four call sites now `.await?` the derivation. Real I/O errors on the `.env` read propagate as `FilesystemError` (matching the CLI's narrow `ENOENT`/`EISDIR` swallow); fragment-rescan read errors are logged and skipped (matching the CLI's broader try/catch over its fragment read + parse). The `name` field is still used as the features image-tag prefix (`generate_features_image_tag`); only the compose project namespace is decoupled from it. **Duplicate-container detection (from #54068).** When `check_for_existing_container`'s label-based lookup returns more than one match, propagate `MultipleMatchingContainers(ids)` with instructions to clean up the stale one(s). This covers the mixed-version upgrade edge case where a pre-fix Zed left a container under the legacy project name alongside a CLI-style one — transparent to users in the common case (one tool, one container), explicit error when two legacy siblings need manual cleanup. ## Why Full write-up with verified fixtures and captured output: #54255. Three failure modes from the same root cause, all resolved by this change: 1. **Interop** — opening a folder in both Zed and `devcontainer up` (or Zed and VS Code) creates two compose projects with identical `devcontainer.local_folder` + `devcontainer.config_file` labels, breaking the spec's uniqueness invariant. 2. **Cross-worktree silent db/volume reuse** — if multiple git worktrees share a `devcontainer.json` with the same `name`, Zed uses the same compose project for all of them; Compose reuses stateful siblings (db, cache, localstack) by config-hash, so worktree B silently inherits worktree A's database. Fixture + captured output: [antont/zed-devcontainer-db-share-repro](https://github.com/antont/zed-devcontainer-db-share-repro). 3. **Mixed-version Zed sessions** — the Rust impl landed in stable v0.232.2 (2026-04-15, #52338). Older Zed (≤v0.231.x) shelled out to `@devcontainers/cli` so it used the reference derivation. The collision shows up when a new-Zed-created container exists under the name-field project while a CLI-derivation tool (old Zed, `devcontainer up`, VS Code) operates on the same folder. ## Migration / compatibility Existing Zed-created containers (under the old `safe_id_lower(name)` project) continue to be found via `check_for_existing_container`'s label-based lookup — they're looked up by `devcontainer.local_folder` + `devcontainer.config_file`, not by project name. A user with duplicate legacy containers from a prior Zed session sees `MultipleMatchingContainers` with cleanup instructions. ## Revision — 2026-04-22 Revised per @KyleBarton review on the prior version: - Swapped the YAML parser from `serde_yaml_ng` to `yaml-rust2` (already transitive via `tree-sitter-yaml`; net reduction of one direct workspace dep; also what #53922 will pull in). - Dropped the mixed-version tiebreak (`pick_canonical_container`) and its `com.docker.compose.project` serde label. The edge case it covered is transient enough to address via the explicit `MultipleMatchingContainers` error rather than permanent tiebreaking code. - Folded #54068's detection commit into this PR; #54068 closed unmerged. - Rebased onto `main`. ## Test plan - [x] `cargo test -p dev_container --lib` — 89 passed, including: - `sanitize_compose_project_name_matches_cli_rules` - `--project-name` assertion added to `test_spawns_devcontainer_with_docker_compose` - `check_for_existing_container_errors_when_multiple_match` - `derive_project_name_env_wins_over_everything` - `derive_project_name_dotenv_wins_over_compose_and_fallback` - `derive_project_name_compose_name_wins_over_fallback` - `derive_project_name_skips_compose_name_when_not_explicitly_declared` - `derive_project_name_omits_suffix_when_compose_file_outside_devcontainer_dir` - `derive_project_name_normalizes_compose_path_for_rule_4` - `compose_fragment_declares_name_detects_top_level_name_key` (covers block, quoted-key, and flow-style roots, plus parse failure → not-declared) - `is_missing_file_error_only_accepts_notfound_and_isadirectory` - [x] `cargo fmt --all` — clean - [x] `./script/clippy -p dev_container` — clean - [x] **End-to-end with fixture** [antont/zed-devcontainer-compose-test](https://github.com/antont/zed-devcontainer-compose-test): - Build `zed` from this branch. - Clean slate: `docker ps -a --filter "label=devcontainer.local_folder=$PWD" -q | xargs -r docker rm -f` - `zed --dev-container /path/to/devcontainer-compose-test` → Zed creates container under project `devcontainer-compose-test_devcontainer` (was `compose_duplicate_repro` before the fix). - `devcontainer up --workspace-folder $PWD` → CLI reports the same `containerId` Zed created; no second compose project is introduced. - Captured: `devcontainer-compose-test_devcontainer-app-1`, `composeProjectName: "devcontainer-compose-test_devcontainer"` reported by both tools. Release Notes: - Fixed dev container Docker Compose project name now matches the full `getProjectName` precedence from the reference devcontainer CLI (`COMPOSE_PROJECT_NAME` in the environment, then in the workspace `.env`, then an explicit top-level `name:` on the merged compose config, then the basename of the first compose file's directory — with the `_devcontainer` suffix only when that directory is `<workspace>/.devcontainer`). This prevents duplicate containers when the same folder is opened with both Zed and the devcontainer CLI / VS Code. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1148 lines
40 KiB
Rust
1148 lines
40 KiB
Rust
use std::{collections::HashMap, path::PathBuf};
|
|
|
|
use async_trait::async_trait;
|
|
use serde::{Deserialize, Deserializer, Serialize, de};
|
|
use util::command::Command;
|
|
|
|
use crate::{
|
|
command_json::evaluate_json_command, devcontainer_api::DevContainerError,
|
|
devcontainer_json::MountDefinition,
|
|
};
|
|
|
|
#[derive(Debug, Deserialize, Serialize, Eq, PartialEq)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
pub(crate) struct DockerPs {
|
|
#[serde(alias = "ID")]
|
|
pub(crate) id: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
pub(crate) struct DockerState {
|
|
pub(crate) running: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
pub(crate) struct DockerInspect {
|
|
pub(crate) id: String,
|
|
pub(crate) config: DockerInspectConfig,
|
|
pub(crate) mounts: Option<Vec<DockerInspectMount>>,
|
|
pub(crate) state: Option<DockerState>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
|
|
pub(crate) struct DockerConfigLabels {
|
|
#[serde(
|
|
default,
|
|
rename = "devcontainer.metadata",
|
|
deserialize_with = "deserialize_metadata"
|
|
)]
|
|
pub(crate) metadata: Option<Vec<HashMap<String, serde_json_lenient::Value>>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
pub(crate) struct DockerInspectConfig {
|
|
#[serde(default, deserialize_with = "deserialize_nullable_labels")]
|
|
pub(crate) labels: DockerConfigLabels,
|
|
#[serde(rename = "User")]
|
|
pub(crate) image_user: Option<String>,
|
|
#[serde(default)]
|
|
pub(crate) env: Vec<String>,
|
|
}
|
|
|
|
impl DockerInspectConfig {
|
|
pub(crate) fn env_as_map(&self) -> Result<HashMap<String, String>, DevContainerError> {
|
|
let mut map = HashMap::new();
|
|
for env_var in &self.env {
|
|
let Some((key, value)) = env_var.split_once('=') else {
|
|
log::warn!("Skipping environment variable without a value: {env_var}");
|
|
continue;
|
|
};
|
|
map.insert(key.to_string(), value.to_string());
|
|
}
|
|
Ok(map)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
pub(crate) struct DockerInspectMount {
|
|
pub(crate) source: String,
|
|
pub(crate) destination: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
|
|
pub(crate) struct DockerComposeServiceBuild {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) context: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) dockerfile: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) target: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) args: Option<HashMap<String, String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) additional_contexts: Option<HashMap<String, String>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
|
|
pub(crate) struct DockerComposeServicePort {
|
|
#[serde(deserialize_with = "deserialize_string_or_int")]
|
|
pub(crate) target: String,
|
|
#[serde(deserialize_with = "deserialize_string_or_int")]
|
|
pub(crate) published: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) mode: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) protocol: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) host_ip: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) app_protocol: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) name: Option<String>,
|
|
}
|
|
|
|
fn deserialize_string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
use serde::Deserialize;
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum StringOrInt {
|
|
String(String),
|
|
Int(u32),
|
|
}
|
|
|
|
match StringOrInt::deserialize(deserializer)? {
|
|
StringOrInt::String(s) => Ok(s),
|
|
StringOrInt::Int(b) => Ok(b.to_string()),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
|
|
pub(crate) struct DockerComposeService {
|
|
pub(crate) image: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) entrypoint: Option<Vec<String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) cap_add: Option<Vec<String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) security_opt: Option<Vec<String>>,
|
|
#[serde(
|
|
skip_serializing_if = "Option::is_none",
|
|
default,
|
|
deserialize_with = "deserialize_labels"
|
|
)]
|
|
pub(crate) labels: Option<HashMap<String, String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) build: Option<DockerComposeServiceBuild>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) privileged: Option<bool>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub(crate) volumes: Vec<MountDefinition>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) env_file: Option<Vec<String>>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub(crate) ports: Vec<DockerComposeServicePort>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) network_mode: Option<String>,
|
|
#[serde(
|
|
default,
|
|
skip_serializing_if = "Vec::is_empty",
|
|
deserialize_with = "deserialize_nullable_vec"
|
|
)]
|
|
pub(crate) command: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
|
|
pub(crate) struct DockerComposeVolume {
|
|
pub(crate) name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
|
|
pub(crate) struct DockerComposeConfig {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub(crate) name: Option<String>,
|
|
pub(crate) services: HashMap<String, DockerComposeService>,
|
|
#[serde(default)]
|
|
pub(crate) volumes: HashMap<String, DockerComposeVolume>,
|
|
}
|
|
|
|
pub(crate) struct Docker {
|
|
docker_cli: String,
|
|
has_buildx: bool,
|
|
}
|
|
|
|
impl DockerInspect {
|
|
pub(crate) fn is_running(&self) -> bool {
|
|
self.state.as_ref().map_or(false, |s| s.running)
|
|
}
|
|
}
|
|
|
|
impl Docker {
|
|
pub(crate) async fn new(docker_cli: &str) -> Self {
|
|
let has_buildx = if docker_cli == "podman" {
|
|
false
|
|
} else {
|
|
let output = Command::new(docker_cli)
|
|
.args(["buildx", "version"])
|
|
.output()
|
|
.await;
|
|
output.map(|o| o.status.success()).unwrap_or(false)
|
|
};
|
|
if !has_buildx && docker_cli != "podman" {
|
|
log::info!(
|
|
"docker buildx not found; dev container builds will use the scratch-image fallback"
|
|
);
|
|
}
|
|
Self {
|
|
docker_cli: docker_cli.to_string(),
|
|
has_buildx,
|
|
}
|
|
}
|
|
|
|
fn is_podman(&self) -> bool {
|
|
self.docker_cli == "podman"
|
|
}
|
|
|
|
async fn pull_image(&self, image: &String) -> Result<(), DevContainerError> {
|
|
let mut command = Command::new(&self.docker_cli);
|
|
command.args(&["pull", "--", image]);
|
|
|
|
let output = command.output().await.map_err(|e| {
|
|
log::error!("Error pulling image: {e}");
|
|
DevContainerError::ResourceFetchFailed
|
|
})?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
log::error!("Non-success result from docker pull: {stderr}");
|
|
return Err(DevContainerError::ResourceFetchFailed);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn create_docker_query_containers(&self, filters: Vec<String>) -> Command {
|
|
let mut command = Command::new(&self.docker_cli);
|
|
command.args(&["ps", "-a"]);
|
|
|
|
for filter in filters {
|
|
command.arg("--filter");
|
|
command.arg(filter);
|
|
}
|
|
command.arg("--format={{ json . }}");
|
|
command
|
|
}
|
|
|
|
fn create_docker_inspect(&self, id: &str) -> Command {
|
|
let mut command = Command::new(&self.docker_cli);
|
|
command.args(&["inspect", "--format={{json . }}", id]);
|
|
command
|
|
}
|
|
|
|
fn create_docker_compose_config_command(&self, config_files: &Vec<PathBuf>) -> Command {
|
|
let mut command = Command::new(&self.docker_cli);
|
|
command.arg("compose");
|
|
for file_path in config_files {
|
|
command.args(&["-f", &file_path.display().to_string()]);
|
|
}
|
|
command.args(&["config", "--format", "json"]);
|
|
command
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl DockerClient for Docker {
|
|
async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError> {
|
|
// Try to pull the image, continue on failure; Image may be local only, id a reference to a running container
|
|
self.pull_image(id).await.ok();
|
|
|
|
let command = self.create_docker_inspect(id);
|
|
|
|
let Some(docker_inspect): Option<DockerInspect> = evaluate_json_command(command).await?
|
|
else {
|
|
log::error!("Docker inspect produced no deserializable output");
|
|
return Err(DevContainerError::CommandFailed(self.docker_cli.clone()));
|
|
};
|
|
Ok(docker_inspect)
|
|
}
|
|
|
|
async fn get_docker_compose_config(
|
|
&self,
|
|
config_files: &Vec<PathBuf>,
|
|
) -> Result<Option<DockerComposeConfig>, DevContainerError> {
|
|
let command = self.create_docker_compose_config_command(config_files);
|
|
evaluate_json_command(command).await
|
|
}
|
|
|
|
async fn docker_compose_build(
|
|
&self,
|
|
config_files: &Vec<PathBuf>,
|
|
project_name: &str,
|
|
) -> Result<(), DevContainerError> {
|
|
let mut command = Command::new(&self.docker_cli);
|
|
if !self.is_podman() {
|
|
command.env("DOCKER_BUILDKIT", "1");
|
|
}
|
|
command.args(&["compose", "--project-name", project_name]);
|
|
for docker_compose_file in config_files {
|
|
command.args(&["-f", &docker_compose_file.display().to_string()]);
|
|
}
|
|
command.arg("build");
|
|
|
|
let output = command.output().await.map_err(|e| {
|
|
log::error!("Error running docker compose up: {e}");
|
|
DevContainerError::CommandFailed(command.get_program().display().to_string())
|
|
})?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
log::error!("Non-success status from docker compose up: {}", stderr);
|
|
return Err(DevContainerError::CommandFailed(
|
|
command.get_program().display().to_string(),
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
async fn run_docker_exec(
|
|
&self,
|
|
container_id: &str,
|
|
remote_folder: &str,
|
|
user: &str,
|
|
env: &HashMap<String, String>,
|
|
inner_command: Command,
|
|
) -> Result<(), DevContainerError> {
|
|
let mut command = Command::new(&self.docker_cli);
|
|
|
|
command.args(&["exec", "-w", remote_folder, "-u", user]);
|
|
|
|
for (k, v) in env.iter() {
|
|
command.arg("-e");
|
|
let env_declaration = format!("{}={}", k, v);
|
|
command.arg(&env_declaration);
|
|
}
|
|
|
|
command.arg(container_id);
|
|
|
|
command.arg("sh");
|
|
|
|
let mut inner_program_script: Vec<String> =
|
|
vec![inner_command.get_program().display().to_string()];
|
|
let mut args: Vec<String> = inner_command
|
|
.get_args()
|
|
.map(|arg| arg.display().to_string())
|
|
.collect();
|
|
inner_program_script.append(&mut args);
|
|
command.args(&["-c", &inner_program_script.join(" ")]);
|
|
|
|
let output = command.output().await.map_err(|e| {
|
|
log::error!("Error running command {e} in container exec");
|
|
DevContainerError::ContainerNotValid(container_id.to_string())
|
|
})?;
|
|
if !output.status.success() {
|
|
let std_err = String::from_utf8_lossy(&output.stderr);
|
|
log::error!("Command produced a non-successful output. StdErr: {std_err}");
|
|
}
|
|
let std_out = String::from_utf8_lossy(&output.stdout);
|
|
log::debug!("Command output:\n {std_out}");
|
|
|
|
Ok(())
|
|
}
|
|
async fn start_container(&self, id: &str) -> Result<(), DevContainerError> {
|
|
let mut command = Command::new(&self.docker_cli);
|
|
|
|
command.args(&["start", id]);
|
|
|
|
let output = command.output().await.map_err(|e| {
|
|
log::error!("Error running docker start: {e}");
|
|
DevContainerError::CommandFailed(command.get_program().display().to_string())
|
|
})?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
log::error!("Non-success status from docker start: {stderr}");
|
|
return Err(DevContainerError::CommandFailed(
|
|
command.get_program().display().to_string(),
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn find_process_by_filters(
|
|
&self,
|
|
filters: Vec<String>,
|
|
) -> Result<Option<DockerPs>, DevContainerError> {
|
|
let mut command = self.create_docker_query_containers(filters);
|
|
let output = command.output().await.map_err(|e| {
|
|
log::error!("Error running command {:?}: {e}", command);
|
|
DevContainerError::CommandFailed(command.get_program().display().to_string())
|
|
})?;
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
log::error!("Non-success status from docker ps: {stderr}");
|
|
return Err(DevContainerError::CommandFailed(
|
|
command.get_program().display().to_string(),
|
|
));
|
|
}
|
|
let raw = String::from_utf8_lossy(&output.stdout);
|
|
parse_find_process_output(&raw).map_err(|e| {
|
|
// Preserve the dedicated multi-match error; log and re-wrap other parse failures.
|
|
if let DevContainerError::MultipleMatchingContainers(_) = &e {
|
|
e
|
|
} else {
|
|
log::error!("Error parsing docker ps output: {e}");
|
|
DevContainerError::CommandFailed(command.get_program().display().to_string())
|
|
}
|
|
})
|
|
}
|
|
|
|
fn docker_cli(&self) -> String {
|
|
self.docker_cli.clone()
|
|
}
|
|
|
|
fn supports_compose_buildkit(&self) -> bool {
|
|
self.has_buildx
|
|
}
|
|
}
|
|
|
|
/// Parses output of `docker ps -a --format={{ json . }}`. When a single
|
|
/// container matches the label filters, docker emits one JSON object; when
|
|
/// multiple match, it emits newline-delimited JSON (one object per line).
|
|
///
|
|
/// Returns `Ok(None)` for no matches, `Ok(Some(_))` for exactly one match,
|
|
/// and `DevContainerError::MultipleMatchingContainers` for ≥2 matches — the
|
|
/// spec expects identifying labels to be unique per project, so the caller
|
|
/// can't silently pick one.
|
|
fn parse_find_process_output(raw: &str) -> Result<Option<DockerPs>, DevContainerError> {
|
|
if raw.trim().is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let containers: Vec<DockerPs> = serde_json_lenient::Deserializer::from_str(raw)
|
|
.into_iter::<DockerPs>()
|
|
.collect::<Result<_, _>>()
|
|
.map_err(|e| {
|
|
DevContainerError::CommandFailed(format!("failed to parse docker ps output: {e}"))
|
|
})?;
|
|
match containers.len() {
|
|
0 => Ok(None),
|
|
1 => Ok(containers.into_iter().next()),
|
|
_ => Err(DevContainerError::MultipleMatchingContainers(
|
|
containers.into_iter().map(|c| c.id).collect(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
pub(crate) trait DockerClient {
|
|
async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError>;
|
|
async fn get_docker_compose_config(
|
|
&self,
|
|
config_files: &Vec<PathBuf>,
|
|
) -> Result<Option<DockerComposeConfig>, DevContainerError>;
|
|
async fn docker_compose_build(
|
|
&self,
|
|
config_files: &Vec<PathBuf>,
|
|
project_name: &str,
|
|
) -> Result<(), DevContainerError>;
|
|
async fn run_docker_exec(
|
|
&self,
|
|
container_id: &str,
|
|
remote_folder: &str,
|
|
user: &str,
|
|
env: &HashMap<String, String>,
|
|
inner_command: Command,
|
|
) -> Result<(), DevContainerError>;
|
|
async fn start_container(&self, id: &str) -> Result<(), DevContainerError>;
|
|
async fn find_process_by_filters(
|
|
&self,
|
|
filters: Vec<String>,
|
|
) -> Result<Option<DockerPs>, DevContainerError>;
|
|
fn supports_compose_buildkit(&self) -> bool;
|
|
/// This operates as an escape hatch for more custom uses of the docker API.
|
|
/// See DevContainerManifest::create_docker_build as an example
|
|
fn docker_cli(&self) -> String;
|
|
}
|
|
|
|
fn deserialize_labels<'de, D>(deserializer: D) -> Result<Option<HashMap<String, String>>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
struct LabelsVisitor;
|
|
|
|
impl<'de> de::Visitor<'de> for LabelsVisitor {
|
|
type Value = Option<HashMap<String, String>>;
|
|
|
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
formatter.write_str("a sequence of strings or a map of string key-value pairs")
|
|
}
|
|
|
|
fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
|
|
where
|
|
A: de::SeqAccess<'de>,
|
|
{
|
|
let values = Vec::<String>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
|
|
|
|
Ok(Some(
|
|
values
|
|
.iter()
|
|
.filter_map(|v| {
|
|
let (key, value) = v.split_once('=')?;
|
|
Some((key.to_string(), value.to_string()))
|
|
})
|
|
.collect(),
|
|
))
|
|
}
|
|
|
|
fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
|
|
where
|
|
M: de::MapAccess<'de>,
|
|
{
|
|
HashMap::<String, String>::deserialize(de::value::MapAccessDeserializer::new(map))
|
|
.map(|v| Some(v))
|
|
}
|
|
|
|
fn visit_none<E>(self) -> Result<Self::Value, E>
|
|
where
|
|
E: de::Error,
|
|
{
|
|
Ok(None)
|
|
}
|
|
|
|
fn visit_unit<E>(self) -> Result<Self::Value, E>
|
|
where
|
|
E: de::Error,
|
|
{
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
deserializer.deserialize_any(LabelsVisitor)
|
|
}
|
|
|
|
fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
T: Deserialize<'de>,
|
|
{
|
|
Option::<Vec<T>>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
|
|
}
|
|
|
|
fn deserialize_nullable_labels<'de, D>(deserializer: D) -> Result<DockerConfigLabels, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
Option::<DockerConfigLabels>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
|
|
}
|
|
|
|
fn deserialize_metadata<'de, D>(
|
|
deserializer: D,
|
|
) -> Result<Option<Vec<HashMap<String, serde_json_lenient::Value>>>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let s: Option<String> = Option::deserialize(deserializer)?;
|
|
match s {
|
|
Some(json_string) => {
|
|
// The devcontainer metadata label can be either a JSON array (e.g. from
|
|
// image-based devcontainers) or a single JSON object (e.g. from
|
|
// docker-compose-based devcontainers created by the devcontainer CLI).
|
|
// Handle both formats.
|
|
let parsed: Vec<HashMap<String, serde_json_lenient::Value>> =
|
|
serde_json_lenient::from_str(&json_string).or_else(|_| {
|
|
let single: HashMap<String, serde_json_lenient::Value> =
|
|
serde_json_lenient::from_str(&json_string).map_err(|e| {
|
|
log::error!("Error deserializing metadata: {e}");
|
|
serde::de::Error::custom(e)
|
|
})?;
|
|
Ok(vec![single])
|
|
})?;
|
|
Ok(Some(parsed))
|
|
}
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use std::{
|
|
collections::HashMap,
|
|
ffi::OsStr,
|
|
process::{ExitStatus, Output},
|
|
};
|
|
|
|
use crate::{
|
|
command_json::deserialize_json_output,
|
|
devcontainer_api::DevContainerError,
|
|
devcontainer_json::MountDefinition,
|
|
docker::{
|
|
Docker, DockerComposeConfig, DockerComposeService, DockerComposeServicePort,
|
|
DockerComposeVolume, DockerInspect, DockerPs, parse_find_process_output,
|
|
},
|
|
};
|
|
|
|
#[test]
|
|
fn should_parse_simple_env_var() {
|
|
let config = super::DockerInspectConfig {
|
|
labels: super::DockerConfigLabels { metadata: None },
|
|
image_user: None,
|
|
env: vec!["KEY=value".to_string()],
|
|
};
|
|
|
|
let map = config.env_as_map().unwrap();
|
|
assert_eq!(map.get("KEY").unwrap(), "value");
|
|
}
|
|
|
|
#[test]
|
|
fn should_parse_env_var_with_equals_in_value() {
|
|
let config = super::DockerInspectConfig {
|
|
labels: super::DockerConfigLabels { metadata: None },
|
|
image_user: None,
|
|
env: vec!["COMPLEX=key=val other>=1.0".to_string()],
|
|
};
|
|
|
|
let map = config.env_as_map().unwrap();
|
|
assert_eq!(map.get("COMPLEX").unwrap(), "key=val other>=1.0");
|
|
}
|
|
|
|
#[test]
|
|
fn should_parse_database_url_with_equals_in_query_string() {
|
|
let config = super::DockerInspectConfig {
|
|
labels: super::DockerConfigLabels { metadata: None },
|
|
image_user: None,
|
|
env: vec![
|
|
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
|
|
"TEST_DATABASE_URL=postgres://postgres:postgres@db:5432/mydb?sslmode=disable"
|
|
.to_string(),
|
|
],
|
|
};
|
|
|
|
let map = config.env_as_map().unwrap();
|
|
assert_eq!(
|
|
map.get("TEST_DATABASE_URL").unwrap(),
|
|
"postgres://postgres:postgres@db:5432/mydb?sslmode=disable"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn should_skip_env_var_without_equals() {
|
|
let config = super::DockerInspectConfig {
|
|
labels: super::DockerConfigLabels { metadata: None },
|
|
image_user: None,
|
|
env: vec![
|
|
"VALID_KEY=valid_value".to_string(),
|
|
"NO_EQUALS_VAR".to_string(),
|
|
"ANOTHER_VALID=value".to_string(),
|
|
],
|
|
};
|
|
|
|
let map = config.env_as_map().unwrap();
|
|
assert_eq!(map.len(), 2);
|
|
assert_eq!(map.get("VALID_KEY").unwrap(), "valid_value");
|
|
assert_eq!(map.get("ANOTHER_VALID").unwrap(), "value");
|
|
assert!(!map.contains_key("NO_EQUALS_VAR"));
|
|
}
|
|
|
|
#[test]
|
|
fn should_parse_simple_label() {
|
|
let json = r#"{"volumes": [], "labels": ["com.example.key=value"]}"#;
|
|
let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
|
|
let labels = service.labels.unwrap();
|
|
assert_eq!(labels.get("com.example.key").unwrap(), "value");
|
|
}
|
|
|
|
#[test]
|
|
fn should_parse_label_with_equals_in_value() {
|
|
let json = r#"{"volumes": [], "labels": ["com.example.key=value=with=equals"]}"#;
|
|
let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
|
|
let labels = service.labels.unwrap();
|
|
assert_eq!(labels.get("com.example.key").unwrap(), "value=with=equals");
|
|
}
|
|
|
|
#[test]
|
|
fn should_create_docker_inspect_command() {
|
|
let docker = Docker {
|
|
docker_cli: "docker".to_string(),
|
|
has_buildx: false,
|
|
};
|
|
let given_id = "given_docker_id";
|
|
|
|
let command = docker.create_docker_inspect(given_id);
|
|
|
|
assert_eq!(
|
|
command.get_args().collect::<Vec<&OsStr>>(),
|
|
vec![
|
|
OsStr::new("inspect"),
|
|
OsStr::new("--format={{json . }}"),
|
|
OsStr::new(given_id)
|
|
]
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn should_deserialize_docker_ps_with_filters() {
|
|
// First, deserializes empty
|
|
let empty_output = Output {
|
|
status: ExitStatus::default(),
|
|
stderr: vec![],
|
|
stdout: String::from("").into_bytes(),
|
|
};
|
|
|
|
let result: Option<DockerPs> = deserialize_json_output(empty_output).unwrap();
|
|
|
|
assert!(result.is_none());
|
|
|
|
let full_output = Output {
|
|
status: ExitStatus::default(),
|
|
stderr: vec![],
|
|
stdout: String::from(r#"
|
|
{
|
|
"Command": "\"/bin/sh -c 'echo Co…\"",
|
|
"CreatedAt": "2026-02-04 15:44:21 -0800 PST",
|
|
"ID": "abdb6ab59573",
|
|
"Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
|
"Labels": "desktop.docker.io/mounts/0/Source=/somepath/cli,desktop.docker.io/mounts/0/SourceKind=hostFile,desktop.docker.io/mounts/0/Target=/workspaces/cli,desktop.docker.io/ports.scheme=v2,dev.containers.features=common,dev.containers.id=base-ubuntu,dev.containers.release=v0.4.24,dev.containers.source=https://github.com/devcontainers/images,dev.containers.timestamp=Fri, 30 Jan 2026 16:52:34 GMT,dev.containers.variant=noble,devcontainer.config_file=/somepath/cli/.devcontainer/dev_container_2/devcontainer.json,devcontainer.local_folder=/somepath/cli,devcontainer.metadata=[{\"id\":\"ghcr.io/devcontainers/features/common-utils:2\"},{\"id\":\"ghcr.io/devcontainers/features/git:1\",\"customizations\":{\"vscode\":{\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes an up-to-date version of Git, built from source as needed, pre-installed and available on the `PATH`.\"}]}}}},{\"remoteUser\":\"vscode\"}],org.opencontainers.image.ref.name=ubuntu,org.opencontainers.image.version=24.04,version=2.1.6",
|
|
"LocalVolumes": "0",
|
|
"Mounts": "/host_mnt/User…",
|
|
"Names": "objective_haslett",
|
|
"Networks": "bridge",
|
|
"Platform": {
|
|
"architecture": "arm64",
|
|
"os": "linux"
|
|
},
|
|
"Ports": "",
|
|
"RunningFor": "47 hours ago",
|
|
"Size": "0B",
|
|
"State": "running",
|
|
"Status": "Up 47 hours"
|
|
}
|
|
"#).into_bytes(),
|
|
};
|
|
|
|
let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
|
|
|
|
assert!(result.is_some());
|
|
let result = result.unwrap();
|
|
assert_eq!(result.id, "abdb6ab59573".to_string());
|
|
|
|
// Podman variant (Id, not ID)
|
|
let full_output = Output {
|
|
status: ExitStatus::default(),
|
|
stderr: vec![],
|
|
stdout: String::from(r#"
|
|
{
|
|
"Command": "\"/bin/sh -c 'echo Co…\"",
|
|
"CreatedAt": "2026-02-04 15:44:21 -0800 PST",
|
|
"Id": "abdb6ab59573",
|
|
"Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
|
"Labels": "desktop.docker.io/mounts/0/Source=/somepath/cli,desktop.docker.io/mounts/0/SourceKind=hostFile,desktop.docker.io/mounts/0/Target=/workspaces/cli,desktop.docker.io/ports.scheme=v2,dev.containers.features=common,dev.containers.id=base-ubuntu,dev.containers.release=v0.4.24,dev.containers.source=https://github.com/devcontainers/images,dev.containers.timestamp=Fri, 30 Jan 2026 16:52:34 GMT,dev.containers.variant=noble,devcontainer.config_file=/somepath/cli/.devcontainer/dev_container_2/devcontainer.json,devcontainer.local_folder=/somepath/cli,devcontainer.metadata=[{\"id\":\"ghcr.io/devcontainers/features/common-utils:2\"},{\"id\":\"ghcr.io/devcontainers/features/git:1\",\"customizations\":{\"vscode\":{\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes an up-to-date version of Git, built from source as needed, pre-installed and available on the `PATH`.\"}]}}}},{\"remoteUser\":\"vscode\"}],org.opencontainers.image.ref.name=ubuntu,org.opencontainers.image.version=24.04,version=2.1.6",
|
|
"LocalVolumes": "0",
|
|
"Mounts": "/host_mnt/User…",
|
|
"Names": "objective_haslett",
|
|
"Networks": "bridge",
|
|
"Platform": {
|
|
"architecture": "arm64",
|
|
"os": "linux"
|
|
},
|
|
"Ports": "",
|
|
"RunningFor": "47 hours ago",
|
|
"Size": "0B",
|
|
"State": "running",
|
|
"Status": "Up 47 hours"
|
|
}
|
|
"#).into_bytes(),
|
|
};
|
|
|
|
let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
|
|
|
|
assert!(result.is_some());
|
|
let result = result.unwrap();
|
|
assert_eq!(result.id, "abdb6ab59573".to_string());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_find_process_output_none() {
|
|
assert!(matches!(parse_find_process_output(""), Ok(None)));
|
|
assert!(matches!(parse_find_process_output(" \n\n"), Ok(None)));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_find_process_output_single() {
|
|
let raw = r#"{"ID":"abc123"}"#;
|
|
let result = parse_find_process_output(raw).expect("single match must parse");
|
|
assert_eq!(result.unwrap().id, "abc123");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_find_process_output_multiple_errors() {
|
|
// `docker ps --format={{ json . }}` emits newline-delimited JSON when
|
|
// multiple containers match the filters. The spec expects the
|
|
// identifying labels to be unique per project, so this is an error.
|
|
let raw = "{\"ID\":\"abc\"}\n{\"ID\":\"def\"}\n";
|
|
match parse_find_process_output(raw) {
|
|
Err(DevContainerError::MultipleMatchingContainers(ids)) => {
|
|
assert_eq!(ids, vec!["abc".to_string(), "def".to_string()]);
|
|
}
|
|
other => panic!("expected MultipleMatchingContainers, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn should_deserialize_object_metadata_from_docker_compose_container() {
|
|
// The devcontainer CLI writes metadata as a bare JSON object (not an array)
|
|
// when there is only one metadata entry (e.g. docker-compose with no features).
|
|
// See https://github.com/devcontainers/cli/issues/1054
|
|
let given_config = r#"
|
|
{
|
|
"Id": "dc4e7b8ff4bf",
|
|
"Config": {
|
|
"Labels": {
|
|
"devcontainer.metadata": "{\"remoteUser\":\"ubuntu\"}"
|
|
}
|
|
}
|
|
}
|
|
"#;
|
|
let config = serde_json_lenient::from_str::<DockerInspect>(given_config).unwrap();
|
|
|
|
assert!(config.config.labels.metadata.is_some());
|
|
let metadata = config.config.labels.metadata.unwrap();
|
|
assert_eq!(metadata.len(), 1);
|
|
assert!(metadata[0].contains_key("remoteUser"));
|
|
assert_eq!(metadata[0]["remoteUser"], "ubuntu");
|
|
}
|
|
|
|
#[test]
|
|
fn should_deserialize_docker_compose_config() {
|
|
let given_config = r#"
|
|
{
|
|
"name": "devcontainer",
|
|
"networks": {
|
|
"default": {
|
|
"name": "devcontainer_default",
|
|
"ipam": {}
|
|
}
|
|
},
|
|
"services": {
|
|
"app": {
|
|
"command": [
|
|
"sleep",
|
|
"infinity"
|
|
],
|
|
"depends_on": {
|
|
"db": {
|
|
"condition": "service_started",
|
|
"restart": true,
|
|
"required": true
|
|
}
|
|
},
|
|
"entrypoint": null,
|
|
"environment": {
|
|
"POSTGRES_DB": "postgres",
|
|
"POSTGRES_HOSTNAME": "localhost",
|
|
"POSTGRES_PASSWORD": "postgres",
|
|
"POSTGRES_PORT": "5432",
|
|
"POSTGRES_USER": "postgres"
|
|
},
|
|
"ports": [
|
|
{
|
|
"target": "5443",
|
|
"published": "5442"
|
|
},
|
|
{
|
|
"name": "custom port",
|
|
"protocol": "udp",
|
|
"host_ip": "127.0.0.1",
|
|
"app_protocol": "http",
|
|
"mode": "host",
|
|
"target": "8081",
|
|
"published": "8083"
|
|
|
|
}
|
|
],
|
|
"image": "mcr.microsoft.com/devcontainers/rust:2-1-bookworm",
|
|
"network_mode": "service:db",
|
|
"volumes": [
|
|
{
|
|
"type": "bind",
|
|
"source": "/path/to",
|
|
"target": "/workspaces",
|
|
"bind": {
|
|
"create_host_path": true
|
|
}
|
|
}
|
|
]
|
|
},
|
|
"db": {
|
|
"command": null,
|
|
"entrypoint": null,
|
|
"environment": {
|
|
"POSTGRES_DB": "postgres",
|
|
"POSTGRES_HOSTNAME": "localhost",
|
|
"POSTGRES_PASSWORD": "postgres",
|
|
"POSTGRES_PORT": "5432",
|
|
"POSTGRES_USER": "postgres"
|
|
},
|
|
"image": "postgres:14.1",
|
|
"networks": {
|
|
"default": null
|
|
},
|
|
"restart": "unless-stopped",
|
|
"volumes": [
|
|
{
|
|
"type": "volume",
|
|
"source": "postgres-data",
|
|
"target": "/var/lib/postgresql/data",
|
|
"volume": {}
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"volumes": {
|
|
"postgres-data": {
|
|
"name": "devcontainer_postgres-data"
|
|
}
|
|
}
|
|
}
|
|
"#;
|
|
|
|
let docker_compose_config: DockerComposeConfig =
|
|
serde_json_lenient::from_str(given_config).unwrap();
|
|
|
|
let expected_config = DockerComposeConfig {
|
|
name: Some("devcontainer".to_string()),
|
|
services: HashMap::from([
|
|
(
|
|
"app".to_string(),
|
|
DockerComposeService {
|
|
command: vec!["sleep".to_string(), "infinity".to_string()],
|
|
image: Some(
|
|
"mcr.microsoft.com/devcontainers/rust:2-1-bookworm".to_string(),
|
|
),
|
|
volumes: vec![MountDefinition {
|
|
mount_type: Some("bind".to_string()),
|
|
source: Some("/path/to".to_string()),
|
|
target: "/workspaces".to_string(),
|
|
}],
|
|
network_mode: Some("service:db".to_string()),
|
|
|
|
ports: vec![
|
|
DockerComposeServicePort {
|
|
target: "5443".to_string(),
|
|
published: "5442".to_string(),
|
|
..Default::default()
|
|
},
|
|
DockerComposeServicePort {
|
|
target: "8081".to_string(),
|
|
published: "8083".to_string(),
|
|
mode: Some("host".to_string()),
|
|
protocol: Some("udp".to_string()),
|
|
host_ip: Some("127.0.0.1".to_string()),
|
|
app_protocol: Some("http".to_string()),
|
|
name: Some("custom port".to_string()),
|
|
},
|
|
],
|
|
..Default::default()
|
|
},
|
|
),
|
|
(
|
|
"db".to_string(),
|
|
DockerComposeService {
|
|
image: Some("postgres:14.1".to_string()),
|
|
volumes: vec![MountDefinition {
|
|
mount_type: Some("volume".to_string()),
|
|
source: Some("postgres-data".to_string()),
|
|
target: "/var/lib/postgresql/data".to_string(),
|
|
}],
|
|
..Default::default()
|
|
},
|
|
),
|
|
]),
|
|
volumes: HashMap::from([(
|
|
"postgres-data".to_string(),
|
|
DockerComposeVolume {
|
|
name: "devcontainer_postgres-data".to_string(),
|
|
},
|
|
)]),
|
|
};
|
|
|
|
assert_eq!(docker_compose_config, expected_config);
|
|
}
|
|
|
|
#[test]
|
|
fn should_deserialize_compose_labels_as_map() {
|
|
let given_config = r#"
|
|
{
|
|
"name": "devcontainer",
|
|
"services": {
|
|
"app": {
|
|
"image": "node:22-alpine",
|
|
"volumes": [],
|
|
"labels": {
|
|
"com.example.test": "value",
|
|
"another.label": "another-value"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"#;
|
|
|
|
let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
|
|
let service = config.services.get("app").unwrap();
|
|
let labels = service.labels.clone().unwrap();
|
|
assert_eq!(
|
|
labels,
|
|
HashMap::from([
|
|
("another.label".to_string(), "another-value".to_string()),
|
|
("com.example.test".to_string(), "value".to_string())
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn should_deserialize_compose_labels_as_array() {
|
|
let given_config = r#"
|
|
{
|
|
"name": "devcontainer",
|
|
"services": {
|
|
"app": {
|
|
"image": "node:22-alpine",
|
|
"volumes": [],
|
|
"labels": ["com.example.test=value"]
|
|
}
|
|
}
|
|
}
|
|
"#;
|
|
|
|
let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
|
|
let service = config.services.get("app").unwrap();
|
|
assert_eq!(
|
|
service.labels,
|
|
Some(HashMap::from([(
|
|
"com.example.test".to_string(),
|
|
"value".to_string()
|
|
)]))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn should_deserialize_compose_without_volumes() {
|
|
let given_config = r#"
|
|
{
|
|
"name": "devcontainer",
|
|
"services": {
|
|
"app": {
|
|
"image": "node:22-alpine",
|
|
"volumes": []
|
|
}
|
|
}
|
|
}
|
|
"#;
|
|
|
|
let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
|
|
assert!(config.volumes.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn should_deserialize_compose_with_missing_volumes_field() {
|
|
let given_config = r#"
|
|
{
|
|
"name": "devcontainer",
|
|
"services": {
|
|
"sidecar": {
|
|
"image": "ubuntu:24.04"
|
|
}
|
|
}
|
|
}
|
|
"#;
|
|
|
|
let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
|
|
let service = config.services.get("sidecar").unwrap();
|
|
assert!(service.volumes.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn should_deserialize_compose_volume_without_source() {
|
|
let given_config = r#"
|
|
{
|
|
"name": "devcontainer",
|
|
"services": {
|
|
"app": {
|
|
"image": "ubuntu:24.04",
|
|
"volumes": [
|
|
{
|
|
"type": "tmpfs",
|
|
"target": "/tmp"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
"#;
|
|
|
|
let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
|
|
let service = config.services.get("app").unwrap();
|
|
assert_eq!(service.volumes.len(), 1);
|
|
assert_eq!(service.volumes[0].source, None);
|
|
assert_eq!(service.volumes[0].target, "/tmp");
|
|
assert_eq!(service.volumes[0].mount_type, Some("tmpfs".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn should_deserialize_inspect_without_labels() {
|
|
let given_config = r#"
|
|
{
|
|
"Id": "sha256:abc123",
|
|
"Config": {
|
|
"Env": ["PATH=/usr/bin"],
|
|
"Cmd": ["node"],
|
|
"WorkingDir": "/"
|
|
}
|
|
}
|
|
"#;
|
|
|
|
let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
|
|
assert!(inspect.config.labels.metadata.is_none());
|
|
assert!(inspect.config.image_user.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn should_deserialize_inspect_with_null_labels() {
|
|
let given_config = r#"
|
|
{
|
|
"Id": "sha256:abc123",
|
|
"Config": {
|
|
"Labels": null,
|
|
"Env": ["PATH=/usr/bin"]
|
|
}
|
|
}
|
|
"#;
|
|
|
|
let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
|
|
assert!(inspect.config.labels.metadata.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn should_deserialize_inspect_with_labels_but_no_metadata() {
|
|
let given_config = r#"
|
|
{
|
|
"Id": "sha256:abc123",
|
|
"Config": {
|
|
"Labels": {
|
|
"com.example.test": "value"
|
|
},
|
|
"Env": ["PATH=/usr/bin"]
|
|
}
|
|
}
|
|
"#;
|
|
|
|
let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
|
|
assert!(inspect.config.labels.metadata.is_none());
|
|
}
|
|
}
|