Files
zed/crates/dev_container/src/docker.rs
T
KyleBarton 4b1bc30b49 Dev containers native implementation (#52338)
## Context

Closes #11473

In-house Zed implementation of devcontainers. Replaces the dependency on
the [reference implementation](https://github.com/devcontainers/cli) via
Node.

This enables additional features with this implementation:
1. Zed extensions can be specified in the `customizations` block, via
this syntax in `devcontainer.json:
```
...
  "customizations": {
    "zed": {
      "extensions": ["vue", "ruby"],
    },
  },

```
2.
[forwardPorts](https://containers.dev/implementors/json_reference/#general-properties)
are supported for multiple ports proxied to the host

## How to Review

<!-- Help reviewers focus their attention:
- For small PRs: note what to focus on (e.g., "error handling in
foo.rs")
- For large PRs (>400 LOC): provide a guided tour — numbered list of
files/commits to read in order. (The `large-pr` label is applied
automatically.)
     - See the review process guidelines for comment conventions -->

## Self-Review Checklist

<!-- Check before requesting review: -->
- [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

Release Notes:

- Improved devcontainer implementation by moving initialization and
creation in-house
2026-04-01 08:16:27 -07:00

899 lines
32 KiB
Rust

use std::{collections::HashMap, path::PathBuf};
use async_trait::async_trait;
use serde::{Deserialize, Deserializer, Serialize};
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)]
pub(crate) struct DockerConfigLabels {
#[serde(
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 {
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 parts: Vec<&str> = env_var.split("=").collect();
if parts.len() != 2 {
log::error!("Unable to parse {env_var} into and environment key-value");
return Err(DevContainerError::DevContainerParseFailed);
}
map.insert(parts[0].to_string(), parts[1].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) 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 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")]
pub(crate) labels: Option<Vec<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>,
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) network_mode: Option<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>,
pub(crate) volumes: HashMap<String, DockerComposeVolume>,
}
pub(crate) struct Docker {
docker_cli: String,
}
impl DockerInspect {
pub(crate) fn is_running(&self) -> bool {
self.state.as_ref().map_or(false, |s| s.running)
}
}
impl Docker {
pub(crate) fn new(docker_cli: &str) -> Self {
Self {
docker_cli: docker_cli.to_string(),
}
}
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 command = self.create_docker_query_containers(filters);
evaluate_json_command(command).await
}
fn docker_cli(&self) -> String {
self.docker_cli.clone()
}
fn supports_compose_buildkit(&self) -> bool {
!self.is_podman()
}
}
#[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_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) => {
let parsed: Vec<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(Some(parsed))
}
None => Ok(None),
}
}
pub(crate) fn get_remote_dir_from_config(
config: &DockerInspect,
local_dir: String,
) -> Result<String, DevContainerError> {
let local_path = PathBuf::from(&local_dir);
let Some(mounts) = &config.mounts else {
log::error!("No mounts defined for container");
return Err(DevContainerError::ContainerNotValid(config.id.clone()));
};
for mount in mounts {
// Sometimes docker will mount the local filesystem on host_mnt for system isolation
let mount_source = PathBuf::from(&mount.source.trim_start_matches("/host_mnt"));
if let Ok(relative_path_to_project) = local_path.strip_prefix(&mount_source) {
let remote_dir = format!(
"{}/{}",
&mount.destination,
relative_path_to_project.display()
);
return Ok(remote_dir);
}
if mount.source == local_dir {
return Ok(mount.destination.clone());
}
}
log::error!("No mounts to local folder");
Err(DevContainerError::ContainerNotValid(config.id.clone()))
}
#[cfg(test)]
mod test {
use std::{
collections::HashMap,
ffi::OsStr,
process::{ExitStatus, Output},
};
use crate::{
command_json::deserialize_json_output,
devcontainer_json::MountDefinition,
docker::{
Docker, DockerComposeConfig, DockerComposeService, DockerComposeVolume, DockerInspect,
DockerPs, get_remote_dir_from_config,
},
};
#[test]
fn should_create_docker_inspect_command() {
let docker = Docker::new("docker");
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 should_get_target_dir_from_docker_inspect() {
let given_config = r#"
{
"Id": "abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85",
"Created": "2026-02-04T23:44:21.802688084Z",
"Path": "/bin/sh",
"Args": [
"-c",
"echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
"-"
],
"State": {
"Status": "running",
"Running": true,
"Paused": false,
"Restarting": false,
"OOMKilled": false,
"Dead": false,
"Pid": 23087,
"ExitCode": 0,
"Error": "",
"StartedAt": "2026-02-04T23:44:21.954875084Z",
"FinishedAt": "0001-01-01T00:00:00Z"
},
"Image": "sha256:3dcb059253b2ebb44de3936620e1cff3dadcd2c1c982d579081ca8128c1eb319",
"ResolvConfPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/resolv.conf",
"HostnamePath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hostname",
"HostsPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hosts",
"LogPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85-json.log",
"Name": "/objective_haslett",
"RestartCount": 0,
"Driver": "overlayfs",
"Platform": "linux",
"MountLabel": "",
"ProcessLabel": "",
"AppArmorProfile": "",
"ExecIDs": [
"008019d93df4107fcbba78bcc6e1ed7e121844f36c26aca1a56284655a6adb53"
],
"HostConfig": {
"Binds": null,
"ContainerIDFile": "",
"LogConfig": {
"Type": "json-file",
"Config": {}
},
"NetworkMode": "bridge",
"PortBindings": {},
"RestartPolicy": {
"Name": "no",
"MaximumRetryCount": 0
},
"AutoRemove": false,
"VolumeDriver": "",
"VolumesFrom": null,
"ConsoleSize": [
0,
0
],
"CapAdd": null,
"CapDrop": null,
"CgroupnsMode": "private",
"Dns": [],
"DnsOptions": [],
"DnsSearch": [],
"ExtraHosts": null,
"GroupAdd": null,
"IpcMode": "private",
"Cgroup": "",
"Links": null,
"OomScoreAdj": 0,
"PidMode": "",
"Privileged": false,
"PublishAllPorts": false,
"ReadonlyRootfs": false,
"SecurityOpt": null,
"UTSMode": "",
"UsernsMode": "",
"ShmSize": 67108864,
"Runtime": "runc",
"Isolation": "",
"CpuShares": 0,
"Memory": 0,
"NanoCpus": 0,
"CgroupParent": "",
"BlkioWeight": 0,
"BlkioWeightDevice": [],
"BlkioDeviceReadBps": [],
"BlkioDeviceWriteBps": [],
"BlkioDeviceReadIOps": [],
"BlkioDeviceWriteIOps": [],
"CpuPeriod": 0,
"CpuQuota": 0,
"CpuRealtimePeriod": 0,
"CpuRealtimeRuntime": 0,
"CpusetCpus": "",
"CpusetMems": "",
"Devices": [],
"DeviceCgroupRules": null,
"DeviceRequests": null,
"MemoryReservation": 0,
"MemorySwap": 0,
"MemorySwappiness": null,
"OomKillDisable": null,
"PidsLimit": null,
"Ulimits": [],
"CpuCount": 0,
"CpuPercent": 0,
"IOMaximumIOps": 0,
"IOMaximumBandwidth": 0,
"Mounts": [
{
"Type": "bind",
"Source": "/somepath/cli",
"Target": "/workspaces/cli",
"Consistency": "cached"
}
],
"MaskedPaths": [
"/proc/asound",
"/proc/acpi",
"/proc/interrupts",
"/proc/kcore",
"/proc/keys",
"/proc/latency_stats",
"/proc/timer_list",
"/proc/timer_stats",
"/proc/sched_debug",
"/proc/scsi",
"/sys/firmware",
"/sys/devices/virtual/powercap"
],
"ReadonlyPaths": [
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger"
]
},
"GraphDriver": {
"Data": null,
"Name": "overlayfs"
},
"Mounts": [
{
"Type": "bind",
"Source": "/somepath/cli",
"Destination": "/workspaces/cli",
"Mode": "",
"RW": true,
"Propagation": "rprivate"
}
],
"Config": {
"Hostname": "abdb6ab59573",
"Domainname": "",
"User": "root",
"AttachStdin": false,
"AttachStdout": true,
"AttachStderr": true,
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"-c",
"echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
"-"
],
"Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": [
"/bin/sh"
],
"OnBuild": null,
"Labels": {
"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"
},
"StopTimeout": 1
},
"NetworkSettings": {
"Bridge": "",
"SandboxID": "2a94990d542fe532deb75f1cc67f761df2d669e3b41161f914079e88516cc54b",
"SandboxKey": "/var/run/docker/netns/2a94990d542f",
"Ports": {},
"HairpinMode": false,
"LinkLocalIPv6Address": "",
"LinkLocalIPv6PrefixLen": 0,
"SecondaryIPAddresses": null,
"SecondaryIPv6Addresses": null,
"EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570",
"Gateway": "172.17.0.1",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"IPAddress": "172.17.0.3",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"MacAddress": "",
"Networks": {
"bridge": {
"IPAMConfig": null,
"Links": null,
"Aliases": null,
"MacAddress": "9a:ec:af:8a:ac:81",
"DriverOpts": null,
"GwPriority": 0,
"NetworkID": "51bb8ccc4d1281db44f16d915963fc728619d4a68e2f90e5ea8f1cb94885063e",
"EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570",
"Gateway": "172.17.0.1",
"IPAddress": "172.17.0.3",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"DNSNames": null
}
}
},
"ImageManifestDescriptor": {
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:39c3436527190561948236894c55b59fa58aa08d68d8867e703c8d5ab72a3593",
"size": 2195,
"platform": {
"architecture": "arm64",
"os": "linux"
}
}
}
"#;
let config = serde_json_lenient::from_str::<DockerInspect>(given_config).unwrap();
let target_dir = get_remote_dir_from_config(&config, "/somepath/cli".to_string());
assert!(target_dir.is_ok());
assert_eq!(target_dir.unwrap(), "/workspaces/cli/".to_string());
}
#[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"
},
"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 {
image: Some(
"mcr.microsoft.com/devcontainers/rust:2-1-bookworm".to_string(),
),
volumes: vec![MountDefinition {
mount_type: Some("bind".to_string()),
source: "/path/to".to_string(),
target: "/workspaces".to_string(),
}],
network_mode: Some("service:db".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: "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);
}
}