Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:29:16.667Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

docker.rs

1494 lines · 53.7 KB · rust
1use std::{collections::HashMap, path::PathBuf};
2
3use async_trait::async_trait;
4use serde::{Deserialize, Deserializer, Serialize, de};
5use util::command::Command;
6
7use crate::{
8    command_json::{evaluate_json_command, evaluate_yaml_command},
9    devcontainer_api::DevContainerError,
10    devcontainer_json::MountDefinition,
11};
12
13#[derive(Debug, Deserialize, Serialize, Eq, PartialEq)]
14#[serde(rename_all = "PascalCase")]
15pub(crate) struct DockerPs {
16    #[serde(alias = "ID")]
17    pub(crate) id: String,
18}
19
20#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
21#[serde(rename_all = "PascalCase")]
22pub(crate) struct DockerState {
23    pub(crate) running: bool,
24    #[serde(default)]
25    pub(crate) started_at: Option<String>,
26}
27
28#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
29#[serde(rename_all = "PascalCase")]
30pub(crate) struct DockerInspect {
31    pub(crate) id: String,
32    pub(crate) config: DockerInspectConfig,
33    pub(crate) mounts: Option<Vec<DockerInspectMount>>,
34    pub(crate) state: Option<DockerState>,
35}
36
37#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
38pub(crate) struct DockerConfigLabels {
39    #[serde(
40        default,
41        rename = "devcontainer.metadata",
42        deserialize_with = "deserialize_metadata"
43    )]
44    pub(crate) metadata: Option<Vec<HashMap<String, serde_json_lenient::Value>>>,
45}
46
47#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
48#[serde(rename_all = "PascalCase")]
49pub(crate) struct DockerInspectConfig {
50    #[serde(default, deserialize_with = "deserialize_nullable_labels")]
51    pub(crate) labels: DockerConfigLabels,
52    #[serde(rename = "User")]
53    pub(crate) image_user: Option<String>,
54    #[serde(default)]
55    pub(crate) env: Vec<String>,
56}
57
58impl DockerInspectConfig {
59    pub(crate) fn env_as_map(&self) -> Result<HashMap<String, String>, DevContainerError> {
60        let mut map = HashMap::new();
61        for env_var in &self.env {
62            let Some((key, value)) = env_var.split_once('=') else {
63                log::warn!("Skipping environment variable without a value: {env_var}");
64                continue;
65            };
66            map.insert(key.to_string(), value.to_string());
67        }
68        Ok(map)
69    }
70}
71
72#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
73#[serde(rename_all = "PascalCase")]
74pub(crate) struct DockerInspectMount {
75    pub(crate) source: String,
76    pub(crate) destination: String,
77}
78
79#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
80pub(crate) struct DockerComposeServiceBuild {
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub(crate) context: Option<String>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub(crate) dockerfile: Option<String>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub(crate) target: Option<String>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub(crate) args: Option<HashMap<String, String>>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub(crate) additional_contexts: Option<HashMap<String, String>>,
91}
92
93#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
94pub(crate) struct DockerComposeServicePort {
95    #[serde(deserialize_with = "deserialize_string_or_int")]
96    pub(crate) target: String,
97    #[serde(deserialize_with = "deserialize_string_or_int")]
98    pub(crate) published: String,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub(crate) mode: Option<String>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub(crate) protocol: Option<String>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub(crate) host_ip: Option<String>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub(crate) app_protocol: Option<String>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub(crate) name: Option<String>,
109}
110
111fn deserialize_string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>
112where
113    D: serde::Deserializer<'de>,
114{
115    use serde::Deserialize;
116
117    #[derive(Deserialize)]
118    #[serde(untagged)]
119    enum StringOrInt {
120        String(String),
121        Int(u32),
122    }
123
124    match StringOrInt::deserialize(deserializer)? {
125        StringOrInt::String(s) => Ok(s),
126        StringOrInt::Int(b) => Ok(b.to_string()),
127    }
128}
129
130#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
131pub(crate) struct DockerComposeService {
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub(crate) image: Option<String>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub(crate) entrypoint: Option<Vec<String>>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub(crate) cap_add: Option<Vec<String>>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub(crate) security_opt: Option<Vec<String>>,
140    #[serde(
141        skip_serializing_if = "Option::is_none",
142        default,
143        deserialize_with = "deserialize_labels"
144    )]
145    pub(crate) labels: Option<HashMap<String, String>>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub(crate) build: Option<DockerComposeServiceBuild>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub(crate) privileged: Option<bool>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub(crate) init: Option<bool>,
152    #[serde(
153        default,
154        skip_serializing_if = "Vec::is_empty",
155        deserialize_with = "deserialize_compose_volumes"
156    )]
157    pub(crate) volumes: Vec<MountDefinition>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub(crate) env_file: Option<Vec<String>>,
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub(crate) ports: Vec<DockerComposeServicePort>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub(crate) network_mode: Option<String>,
164    #[serde(
165        default,
166        skip_serializing_if = "Vec::is_empty",
167        deserialize_with = "deserialize_nullable_vec"
168    )]
169    pub(crate) command: Vec<String>,
170    #[serde(
171        skip_serializing_if = "Option::is_none",
172        default,
173        deserialize_with = "deserialize_environment"
174    )]
175    pub(crate) environment: Option<HashMap<String, String>>,
176}
177
178#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
179pub(crate) struct DockerComposeVolume {
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub(crate) name: Option<String>,
182}
183
184#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
185pub(crate) struct DockerComposeConfig {
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub(crate) name: Option<String>,
188    pub(crate) services: HashMap<String, DockerComposeService>,
189    #[serde(default, deserialize_with = "deserialize_compose_top_level_volumes")]
190    pub(crate) volumes: HashMap<String, DockerComposeVolume>,
191}
192
193pub(crate) struct Docker {
194    docker_cli: String,
195    has_buildx: bool,
196}
197
198impl DockerInspect {
199    pub(crate) fn is_running(&self) -> bool {
200        self.state.as_ref().map_or(false, |s| s.running)
201    }
202}
203
204impl Docker {
205    pub(crate) async fn new(docker_cli: &str, use_buildkit: Option<bool>) -> Self {
206        let has_buildx = if docker_cli == "podman" {
207            false
208        } else if let Some(use_buildkit) = use_buildkit {
209            // Honor the explicit `dev_container_use_buildkit` setting. Setting it
210            // to `false` forces the classic Docker builder for Docker-compatible
211            // engines that lack an integrated BuildKit (e.g. Apple Container via
212            // a Docker-API bridge), where BuildKit builds cannot resolve
213            // locally-built images. The classic builder builds the feature
214            // content as an image and references it with an ordinary
215            // multi-stage `FROM`.
216            use_buildkit
217        } else {
218            let output = Command::new(docker_cli)
219                .args(["buildx", "version"])
220                .output()
221                .await;
222            output.map(|o| o.status.success()).unwrap_or(false)
223        };
224        if !has_buildx && docker_cli != "podman" {
225            log::info!(
226                "Using the classic Docker builder for dev container builds (BuildKit unavailable or disabled)"
227            );
228        }
229        Self {
230            docker_cli: docker_cli.to_string(),
231            has_buildx,
232        }
233    }
234
235    fn is_podman(&self) -> bool {
236        self.docker_cli == "podman"
237    }
238
239    async fn pull_image(&self, image: &String) -> Result<(), DevContainerError> {
240        let mut command = Command::new(&self.docker_cli);
241        command.args(&["pull", "--", image]);
242
243        let output = command.output().await.map_err(|e| {
244            log::error!("Error pulling image: {e}");
245            DevContainerError::ResourceFetchFailed
246        })?;
247
248        if !output.status.success() {
249            let stderr = String::from_utf8_lossy(&output.stderr);
250            log::error!("Non-success result from docker pull: {stderr}");
251            return Err(DevContainerError::ResourceFetchFailed);
252        }
253        Ok(())
254    }
255
256    fn create_docker_query_containers(&self, filters: Vec<String>) -> Command {
257        let mut command = Command::new(&self.docker_cli);
258        command.args(&["ps", "-a"]);
259
260        for filter in filters {
261            command.arg("--filter");
262            command.arg(filter);
263        }
264        command.arg("--format={{ json . }}");
265        command
266    }
267
268    fn create_docker_inspect(&self, id: &str) -> Command {
269        let mut command = Command::new(&self.docker_cli);
270        command.args(&["inspect", "--format={{json . }}", id]);
271        command
272    }
273
274    fn create_docker_compose_config_command(&self, config_files: &Vec<PathBuf>) -> Command {
275        let mut command = Command::new(&self.docker_cli);
276        command.arg("compose");
277        for file_path in config_files {
278            command.args(&["-f", &file_path.display().to_string()]);
279        }
280        command.arg("config");
281        command
282    }
283}
284
285#[async_trait]
286impl DockerClient for Docker {
287    async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError> {
288        // Always try inspect first — avoid pulling unless necessary.
289        let command = self.create_docker_inspect(id);
290        match evaluate_json_command::<DockerInspect>(command).await {
291            Ok(Some(docker_inspect)) => return Ok(docker_inspect),
292            Ok(None) | Err(_) => {}
293        }
294
295        // Inspect failed — try pulling and retry.
296        self.pull_image(id).await.ok();
297
298        let command = self.create_docker_inspect(id);
299        let Some(docker_inspect): Option<DockerInspect> = evaluate_json_command(command).await?
300        else {
301            log::error!("Docker inspect produced no deserializable output");
302            return Err(DevContainerError::CommandFailed(self.docker_cli.clone()));
303        };
304        Ok(docker_inspect)
305    }
306
307    async fn get_docker_compose_config(
308        &self,
309        config_files: &Vec<PathBuf>,
310    ) -> Result<Option<DockerComposeConfig>, DevContainerError> {
311        let command = self.create_docker_compose_config_command(config_files);
312        evaluate_yaml_command(command).await
313    }
314
315    async fn docker_compose_build(
316        &self,
317        config_files: &Vec<PathBuf>,
318        project_name: &str,
319        services: Option<&Vec<String>>,
320    ) -> Result<(), DevContainerError> {
321        let mut command = Command::new(&self.docker_cli);
322        if !self.is_podman() {
323            if self.has_buildx {
324                command.env("DOCKER_BUILDKIT", "1");
325            } else {
326                // Without a usable BuildKit, build through the classic builder so
327                // multi-stage `FROM` of locally-built images (the feature content
328                // image) resolves from the daemon's image store.
329                command.env("DOCKER_BUILDKIT", "0");
330                command.env("COMPOSE_DOCKER_CLI_BUILD", "0");
331            }
332        }
333        command.args(&["compose", "--project-name", project_name]);
334        for docker_compose_file in config_files {
335            command.args(&["-f", &docker_compose_file.display().to_string()]);
336        }
337        command.arg("build");
338        if let Some(services) = services {
339            command.args(services);
340        }
341
342        let output = command.output().await.map_err(|e| {
343            log::error!("Error running docker compose up: {e}");
344            DevContainerError::CommandFailed(command.get_program().display().to_string())
345        })?;
346
347        if !output.status.success() {
348            let stderr = String::from_utf8_lossy(&output.stderr);
349            log::error!("Non-success status from docker compose up: {}", stderr);
350            return Err(DevContainerError::CommandFailed(
351                command.get_program().display().to_string(),
352            ));
353        }
354
355        Ok(())
356    }
357    async fn run_docker_exec(
358        &self,
359        container_id: &str,
360        remote_folder: &str,
361        user: &str,
362        env: &HashMap<String, String>,
363        inner_command: Command,
364    ) -> Result<(), DevContainerError> {
365        let mut command = Command::new(&self.docker_cli);
366
367        command.args(&["exec", "-w", remote_folder, "-u", user]);
368
369        for (k, v) in env.iter() {
370            command.arg("-e");
371            let env_declaration = format!("{}={}", k, v);
372            command.arg(&env_declaration);
373        }
374
375        command.arg(container_id);
376
377        command.arg("sh");
378
379        let mut inner_program_script: Vec<String> =
380            vec![inner_command.get_program().display().to_string()];
381        let mut args: Vec<String> = inner_command
382            .get_args()
383            .map(|arg| arg.display().to_string())
384            .collect();
385        inner_program_script.append(&mut args);
386        command.args(&["-c", &inner_program_script.join(" ")]);
387
388        let output = command.output().await.map_err(|e| {
389            log::error!("Error running command {e} in container exec");
390            DevContainerError::ContainerNotValid(container_id.to_string())
391        })?;
392        let std_out = String::from_utf8_lossy(&output.stdout);
393        log::debug!("Command output:\n {std_out}");
394        if !output.status.success() {
395            let std_err = String::from_utf8_lossy(&output.stderr);
396            log::error!("Command produced a non-successful output. StdErr: {std_err}");
397            return Err(DevContainerError::DevContainerScriptsFailed);
398        }
399
400        Ok(())
401    }
402    async fn start_container(&self, id: &str) -> Result<(), DevContainerError> {
403        let mut command = Command::new(&self.docker_cli);
404
405        command.args(&["start", id]);
406
407        let output = command.output().await.map_err(|e| {
408            log::error!("Error running docker start: {e}");
409            DevContainerError::CommandFailed(command.get_program().display().to_string())
410        })?;
411
412        if !output.status.success() {
413            let stderr = String::from_utf8_lossy(&output.stderr);
414            log::error!("Non-success status from docker start: {stderr}");
415            return Err(DevContainerError::CommandFailed(
416                command.get_program().display().to_string(),
417            ));
418        }
419
420        Ok(())
421    }
422
423    async fn find_process_by_filters(
424        &self,
425        filters: Vec<String>,
426    ) -> Result<Option<DockerPs>, DevContainerError> {
427        let mut command = self.create_docker_query_containers(filters);
428        let output = command.output().await.map_err(|e| {
429            log::error!("Error running command {:?}: {e}", command);
430            DevContainerError::CommandFailed(command.get_program().display().to_string())
431        })?;
432        if !output.status.success() {
433            let stderr = String::from_utf8_lossy(&output.stderr);
434            log::error!("Non-success status from docker ps: {stderr}");
435            return Err(DevContainerError::CommandFailed(
436                command.get_program().display().to_string(),
437            ));
438        }
439        let raw = String::from_utf8_lossy(&output.stdout);
440        parse_find_process_output(&raw).map_err(|e| {
441            // Preserve the dedicated multi-match error; log and re-wrap other parse failures.
442            if let DevContainerError::MultipleMatchingContainers(_) = &e {
443                e
444            } else {
445                log::error!("Error parsing docker ps output: {e}");
446                DevContainerError::CommandFailed(command.get_program().display().to_string())
447            }
448        })
449    }
450
451    fn docker_cli(&self) -> String {
452        self.docker_cli.clone()
453    }
454
455    fn supports_compose_buildkit(&self) -> bool {
456        self.has_buildx
457    }
458}
459
460/// Parses output of `docker ps -a --format={{ json . }}`. When a single
461/// container matches the label filters, docker emits one JSON object; when
462/// multiple match, it emits newline-delimited JSON (one object per line).
463///
464/// Returns `Ok(None)` for no matches, `Ok(Some(_))` for exactly one match,
465/// and `DevContainerError::MultipleMatchingContainers` for ≥2 matches — the
466/// spec expects identifying labels to be unique per project, so the caller
467/// can't silently pick one.
468fn parse_find_process_output(raw: &str) -> Result<Option<DockerPs>, DevContainerError> {
469    if raw.trim().is_empty() {
470        return Ok(None);
471    }
472    let containers: Vec<DockerPs> = serde_json_lenient::Deserializer::from_str(raw)
473        .into_iter::<DockerPs>()
474        .collect::<Result<_, _>>()
475        .map_err(|e| {
476            DevContainerError::CommandFailed(format!("failed to parse docker ps output: {e}"))
477        })?;
478    match containers.len() {
479        0 => Ok(None),
480        1 => Ok(containers.into_iter().next()),
481        _ => Err(DevContainerError::MultipleMatchingContainers(
482            containers.into_iter().map(|c| c.id).collect(),
483        )),
484    }
485}
486
487#[async_trait]
488pub(crate) trait DockerClient {
489    async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError>;
490    async fn get_docker_compose_config(
491        &self,
492        config_files: &Vec<PathBuf>,
493    ) -> Result<Option<DockerComposeConfig>, DevContainerError>;
494    async fn docker_compose_build(
495        &self,
496        config_files: &Vec<PathBuf>,
497        project_name: &str,
498        services: Option<&Vec<String>>,
499    ) -> Result<(), DevContainerError>;
500    async fn run_docker_exec(
501        &self,
502        container_id: &str,
503        remote_folder: &str,
504        user: &str,
505        env: &HashMap<String, String>,
506        inner_command: Command,
507    ) -> Result<(), DevContainerError>;
508    async fn start_container(&self, id: &str) -> Result<(), DevContainerError>;
509    async fn find_process_by_filters(
510        &self,
511        filters: Vec<String>,
512    ) -> Result<Option<DockerPs>, DevContainerError>;
513    fn supports_compose_buildkit(&self) -> bool;
514    /// This operates as an escape hatch for more custom uses of the docker API.
515    /// See DevContainerManifest::create_docker_build as an example
516    fn docker_cli(&self) -> String;
517}
518
519fn deserialize_environment<'de, D>(
520    deserializer: D,
521) -> Result<Option<HashMap<String, String>>, D::Error>
522where
523    D: Deserializer<'de>,
524{
525    let Some(value) = Option::<serde_json_lenient::Value>::deserialize(deserializer)? else {
526        return Ok(None);
527    };
528
529    match value {
530        serde_json_lenient::Value::Object(object) => Ok(Some(
531            object
532                .into_iter()
533                .filter_map(|(key, value)| match value {
534                    serde_json_lenient::Value::Null => None,
535                    serde_json_lenient::Value::String(value) => Some((key, value)),
536                    other => Some((key, other.to_string())),
537                })
538                .collect(),
539        )),
540        serde_json_lenient::Value::Array(values) => Ok(Some(
541            values
542                .into_iter()
543                .filter_map(|value| {
544                    let value = value.as_str()?;
545                    let (key, value) = value.split_once('=').unwrap_or((value, ""));
546                    Some((key.to_string(), value.to_string()))
547                })
548                .collect(),
549        )),
550        _ => Ok(None),
551    }
552}
553
554fn deserialize_labels<'de, D>(deserializer: D) -> Result<Option<HashMap<String, String>>, D::Error>
555where
556    D: Deserializer<'de>,
557{
558    struct LabelsVisitor;
559
560    impl<'de> de::Visitor<'de> for LabelsVisitor {
561        type Value = Option<HashMap<String, String>>;
562
563        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
564            formatter.write_str("a sequence of strings or a map of string key-value pairs")
565        }
566
567        fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
568        where
569            A: de::SeqAccess<'de>,
570        {
571            let values = Vec::<String>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
572
573            Ok(Some(
574                values
575                    .iter()
576                    .filter_map(|v| {
577                        let (key, value) = v.split_once('=')?;
578                        Some((key.to_string(), value.to_string()))
579                    })
580                    .collect(),
581            ))
582        }
583
584        fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
585        where
586            M: de::MapAccess<'de>,
587        {
588            HashMap::<String, String>::deserialize(de::value::MapAccessDeserializer::new(map))
589                .map(|v| Some(v))
590        }
591
592        fn visit_none<E>(self) -> Result<Self::Value, E>
593        where
594            E: de::Error,
595        {
596            Ok(None)
597        }
598
599        fn visit_unit<E>(self) -> Result<Self::Value, E>
600        where
601            E: de::Error,
602        {
603            Ok(None)
604        }
605    }
606
607    deserializer.deserialize_any(LabelsVisitor)
608}
609
610fn deserialize_compose_volumes<'de, D>(deserializer: D) -> Result<Vec<MountDefinition>, D::Error>
611where
612    D: Deserializer<'de>,
613{
614    #[derive(Deserialize)]
615    #[serde(untagged)]
616    enum VolumeItem {
617        Object(MountDefinition),
618        String(String),
619    }
620
621    let items = Vec::<VolumeItem>::deserialize(deserializer)?;
622    items
623        .into_iter()
624        .map(|item| match item {
625            VolumeItem::Object(mount) => Ok(mount),
626            VolumeItem::String(s) => parse_compose_volume_string(&s)
627                .ok_or_else(|| de::Error::custom(format!("invalid volume string: {s}"))),
628        })
629        .collect()
630}
631
632/// Parses Docker Compose short volume syntax: `[SOURCE:]TARGET[:MODE]`.
633/// A leading drive letter (e.g. `C:`) on the source is treated as part of the
634/// path rather than as a source/target separator.
635fn parse_compose_volume_string(s: &str) -> Option<MountDefinition> {
636    let bytes = s.as_bytes();
637
638    // Find the colon that separates source from target, skipping a possible
639    // Windows drive-letter prefix (single ASCII letter followed by `:`).
640    let separator_start = if bytes.len() >= 2
641        && bytes[0].is_ascii_alphabetic()
642        && bytes[1] == b':'
643        && bytes.get(2).map_or(false, |&b| b == b'/' || b == b'\\')
644    {
645        // Skip past the drive letter prefix (e.g. "C:\")
646        3
647    } else {
648        0
649    };
650
651    if let Some(colon_pos) = s[separator_start..].find(':') {
652        let colon_pos = colon_pos + separator_start;
653        let source = &s[..colon_pos];
654
655        let rest = &s[colon_pos + 1..];
656
657        // `rest` may itself start with a Windows drive letter, so skip past
658        // that before looking for a second colon that would delimit the mode.
659        let mode_search_start = if rest.len() >= 2
660            && rest.as_bytes()[0].is_ascii_alphabetic()
661            && rest.as_bytes()[1] == b':'
662        {
663            2
664        } else {
665            0
666        };
667
668        let (target, _mode) = if let Some(pos) = rest[mode_search_start..].find(':') {
669            let pos = pos + mode_search_start;
670            (&rest[..pos], Some(&rest[pos + 1..]))
671        } else {
672            (rest, None)
673        };
674
675        if target.is_empty() {
676            return None;
677        }
678
679        Some(MountDefinition {
680            source: Some(source.to_string()),
681            target: target.to_string(),
682            mount_type: None,
683        })
684    } else {
685        // No colon at all — anonymous volume with only a target path
686        if s.is_empty() {
687            return None;
688        }
689        Some(MountDefinition {
690            source: None,
691            target: s.to_string(),
692            mount_type: None,
693        })
694    }
695}
696
697fn deserialize_compose_top_level_volumes<'de, D>(
698    deserializer: D,
699) -> Result<HashMap<String, DockerComposeVolume>, D::Error>
700where
701    D: Deserializer<'de>,
702{
703    let map: HashMap<String, Option<DockerComposeVolume>> = HashMap::deserialize(deserializer)?;
704    Ok(map
705        .into_iter()
706        .map(|(key, value)| (key, value.unwrap_or_default()))
707        .collect())
708}
709
710fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
711where
712    D: Deserializer<'de>,
713    T: Deserialize<'de>,
714{
715    Option::<Vec<T>>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
716}
717
718fn deserialize_nullable_labels<'de, D>(deserializer: D) -> Result<DockerConfigLabels, D::Error>
719where
720    D: Deserializer<'de>,
721{
722    Option::<DockerConfigLabels>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
723}
724
725fn deserialize_metadata<'de, D>(
726    deserializer: D,
727) -> Result<Option<Vec<HashMap<String, serde_json_lenient::Value>>>, D::Error>
728where
729    D: Deserializer<'de>,
730{
731    let s: Option<String> = Option::deserialize(deserializer)?;
732    match s {
733        Some(json_string) => {
734            // The devcontainer metadata label can be either a JSON array (e.g. from
735            // image-based devcontainers) or a single JSON object (e.g. from
736            // docker-compose-based devcontainers created by the devcontainer CLI).
737            // Handle both formats.
738            let parsed: Vec<HashMap<String, serde_json_lenient::Value>> =
739                serde_json_lenient::from_str(&json_string).or_else(|_| {
740                    let single: HashMap<String, serde_json_lenient::Value> =
741                        serde_json_lenient::from_str(&json_string).map_err(|e| {
742                            log::error!("Error deserializing metadata: {e}");
743                            serde::de::Error::custom(e)
744                        })?;
745                    Ok(vec![single])
746                })?;
747            Ok(Some(parsed))
748        }
749        None => Ok(None),
750    }
751}
752
753#[cfg(test)]
754mod test {
755    use std::{
756        collections::HashMap,
757        ffi::OsStr,
758        process::{ExitStatus, Output},
759    };
760
761    use crate::{
762        command_json::deserialize_json_output,
763        devcontainer_api::DevContainerError,
764        devcontainer_json::MountDefinition,
765        docker::{
766            Docker, DockerClient, DockerComposeConfig, DockerComposeService,
767            DockerComposeServicePort, DockerComposeVolume, DockerInspect, DockerPs,
768            parse_find_process_output,
769        },
770    };
771    #[cfg(not(target_os = "windows"))]
772    use util::command::Command;
773
774    #[test]
775    fn use_buildkit_setting_overrides_buildx_detection() {
776        // `Some(_)` short-circuits the `buildx version` probe, so these run
777        // without invoking docker.
778        let forced_off = futures::executor::block_on(Docker::new("docker", Some(false)));
779        assert!(
780            !forced_off.supports_compose_buildkit(),
781            "use_buildkit=false must force the classic builder"
782        );
783
784        let forced_on = futures::executor::block_on(Docker::new("docker", Some(true)));
785        assert!(
786            forced_on.supports_compose_buildkit(),
787            "use_buildkit=true must enable BuildKit"
788        );
789
790        // podman never supports the BuildKit/buildx path, regardless of the setting.
791        let podman = futures::executor::block_on(Docker::new("podman", Some(true)));
792        assert!(!podman.supports_compose_buildkit());
793    }
794
795    #[test]
796    fn should_parse_simple_env_var() {
797        let config = super::DockerInspectConfig {
798            labels: super::DockerConfigLabels { metadata: None },
799            image_user: None,
800            env: vec!["KEY=value".to_string()],
801        };
802
803        let map = config.env_as_map().unwrap();
804        assert_eq!(map.get("KEY").unwrap(), "value");
805    }
806
807    #[test]
808    fn should_parse_env_var_with_equals_in_value() {
809        let config = super::DockerInspectConfig {
810            labels: super::DockerConfigLabels { metadata: None },
811            image_user: None,
812            env: vec!["COMPLEX=key=val other>=1.0".to_string()],
813        };
814
815        let map = config.env_as_map().unwrap();
816        assert_eq!(map.get("COMPLEX").unwrap(), "key=val other>=1.0");
817    }
818
819    #[test]
820    fn should_parse_database_url_with_equals_in_query_string() {
821        let config = super::DockerInspectConfig {
822            labels: super::DockerConfigLabels { metadata: None },
823            image_user: None,
824            env: vec![
825                "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
826                "TEST_DATABASE_URL=postgres://postgres:postgres@db:5432/mydb?sslmode=disable"
827                    .to_string(),
828            ],
829        };
830
831        let map = config.env_as_map().unwrap();
832        assert_eq!(
833            map.get("TEST_DATABASE_URL").unwrap(),
834            "postgres://postgres:postgres@db:5432/mydb?sslmode=disable"
835        );
836    }
837
838    #[test]
839    fn should_skip_env_var_without_equals() {
840        let config = super::DockerInspectConfig {
841            labels: super::DockerConfigLabels { metadata: None },
842            image_user: None,
843            env: vec![
844                "VALID_KEY=valid_value".to_string(),
845                "NO_EQUALS_VAR".to_string(),
846                "ANOTHER_VALID=value".to_string(),
847            ],
848        };
849
850        let map = config.env_as_map().unwrap();
851        assert_eq!(map.len(), 2);
852        assert_eq!(map.get("VALID_KEY").unwrap(), "valid_value");
853        assert_eq!(map.get("ANOTHER_VALID").unwrap(), "value");
854        assert!(!map.contains_key("NO_EQUALS_VAR"));
855    }
856
857    #[test]
858    fn should_parse_simple_label() {
859        let json = r#"{"volumes": [], "labels": ["com.example.key=value"]}"#;
860        let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
861        let labels = service.labels.unwrap();
862        assert_eq!(labels.get("com.example.key").unwrap(), "value");
863    }
864
865    #[test]
866    fn should_parse_label_with_equals_in_value() {
867        let json = r#"{"volumes": [], "labels": ["com.example.key=value=with=equals"]}"#;
868        let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
869        let labels = service.labels.unwrap();
870        assert_eq!(labels.get("com.example.key").unwrap(), "value=with=equals");
871    }
872
873    #[test]
874    fn should_create_docker_inspect_command() {
875        let docker = Docker {
876            docker_cli: "docker".to_string(),
877            has_buildx: false,
878        };
879        let given_id = "given_docker_id";
880
881        let command = docker.create_docker_inspect(given_id);
882
883        assert_eq!(
884            command.get_args().collect::<Vec<&OsStr>>(),
885            vec![
886                OsStr::new("inspect"),
887                OsStr::new("--format={{json . }}"),
888                OsStr::new(given_id)
889            ]
890        )
891    }
892
893    #[cfg(not(target_os = "windows"))]
894    #[test]
895    fn docker_exec_returns_error_on_nonzero_exit() {
896        let docker = Docker {
897            docker_cli: "false".to_string(),
898            has_buildx: false,
899        };
900
901        let result = gpui::block_on(docker.run_docker_exec(
902            "container",
903            "/workspace",
904            "root",
905            &HashMap::new(),
906            Command::new("true"),
907        ));
908
909        assert!(matches!(
910            result,
911            Err(DevContainerError::DevContainerScriptsFailed)
912        ));
913    }
914
915    #[test]
916    fn should_deserialize_docker_ps_with_filters() {
917        // First, deserializes empty
918        let empty_output = Output {
919            status: ExitStatus::default(),
920            stderr: vec![],
921            stdout: String::from("").into_bytes(),
922        };
923
924        let result: Option<DockerPs> = deserialize_json_output(empty_output).unwrap();
925
926        assert!(result.is_none());
927
928        let full_output = Output {
929                status: ExitStatus::default(),
930                stderr: vec![],
931                stdout: String::from(r#"
932    {
933        "Command": "\"/bin/sh -c 'echo Co…\"",
934        "CreatedAt": "2026-02-04 15:44:21 -0800 PST",
935        "ID": "abdb6ab59573",
936        "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
937        "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",
938        "LocalVolumes": "0",
939        "Mounts": "/host_mnt/User…",
940        "Names": "objective_haslett",
941        "Networks": "bridge",
942        "Platform": {
943        "architecture": "arm64",
944        "os": "linux"
945        },
946        "Ports": "",
947        "RunningFor": "47 hours ago",
948        "Size": "0B",
949        "State": "running",
950        "Status": "Up 47 hours"
951    }
952                    "#).into_bytes(),
953            };
954
955        let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
956
957        assert!(result.is_some());
958        let result = result.unwrap();
959        assert_eq!(result.id, "abdb6ab59573".to_string());
960
961        // Podman variant (Id, not ID)
962        let full_output = Output {
963                status: ExitStatus::default(),
964                stderr: vec![],
965                stdout: String::from(r#"
966    {
967        "Command": "\"/bin/sh -c 'echo Co…\"",
968        "CreatedAt": "2026-02-04 15:44:21 -0800 PST",
969        "Id": "abdb6ab59573",
970        "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
971        "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",
972        "LocalVolumes": "0",
973        "Mounts": "/host_mnt/User…",
974        "Names": "objective_haslett",
975        "Networks": "bridge",
976        "Platform": {
977        "architecture": "arm64",
978        "os": "linux"
979        },
980        "Ports": "",
981        "RunningFor": "47 hours ago",
982        "Size": "0B",
983        "State": "running",
984        "Status": "Up 47 hours"
985    }
986                    "#).into_bytes(),
987            };
988
989        let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
990
991        assert!(result.is_some());
992        let result = result.unwrap();
993        assert_eq!(result.id, "abdb6ab59573".to_string());
994    }
995
996    #[test]
997    fn parse_find_process_output_none() {
998        assert!(matches!(parse_find_process_output(""), Ok(None)));
999        assert!(matches!(parse_find_process_output("   \n\n"), Ok(None)));
1000    }
1001
1002    #[test]
1003    fn parse_find_process_output_single() {
1004        let raw = r#"{"ID":"abc123"}"#;
1005        let result = parse_find_process_output(raw).expect("single match must parse");
1006        assert_eq!(result.unwrap().id, "abc123");
1007    }
1008
1009    #[test]
1010    fn parse_find_process_output_multiple_errors() {
1011        // `docker ps --format={{ json . }}` emits newline-delimited JSON when
1012        // multiple containers match the filters. The spec expects the
1013        // identifying labels to be unique per project, so this is an error.
1014        let raw = "{\"ID\":\"abc\"}\n{\"ID\":\"def\"}\n";
1015        match parse_find_process_output(raw) {
1016            Err(DevContainerError::MultipleMatchingContainers(ids)) => {
1017                assert_eq!(ids, vec!["abc".to_string(), "def".to_string()]);
1018            }
1019            other => panic!("expected MultipleMatchingContainers, got {other:?}"),
1020        }
1021    }
1022
1023    #[test]
1024    fn should_deserialize_object_metadata_from_docker_compose_container() {
1025        // The devcontainer CLI writes metadata as a bare JSON object (not an array)
1026        // when there is only one metadata entry (e.g. docker-compose with no features).
1027        // See https://github.com/devcontainers/cli/issues/1054
1028        let given_config = r#"
1029    {
1030      "Id": "dc4e7b8ff4bf",
1031      "Config": {
1032        "Labels": {
1033          "devcontainer.metadata": "{\"remoteUser\":\"ubuntu\"}"
1034        }
1035      }
1036    }
1037                "#;
1038        let config = serde_json_lenient::from_str::<DockerInspect>(given_config).unwrap();
1039
1040        assert!(config.config.labels.metadata.is_some());
1041        let metadata = config.config.labels.metadata.unwrap();
1042        assert_eq!(metadata.len(), 1);
1043        assert!(metadata[0].contains_key("remoteUser"));
1044        assert_eq!(metadata[0]["remoteUser"], "ubuntu");
1045    }
1046
1047    #[test]
1048    fn should_deserialize_docker_compose_config() {
1049        let given_config = r#"
1050    {
1051        "name": "devcontainer",
1052        "networks": {
1053        "default": {
1054            "name": "devcontainer_default",
1055            "ipam": {}
1056        }
1057        },
1058        "services": {
1059            "app": {
1060                "command": [
1061                "sleep",
1062                "infinity"
1063                ],
1064                "depends_on": {
1065                "db": {
1066                    "condition": "service_started",
1067                    "restart": true,
1068                    "required": true
1069                }
1070                },
1071                "entrypoint": null,
1072                "environment": {
1073                "POSTGRES_DB": "postgres",
1074                "POSTGRES_HOSTNAME": "localhost",
1075                "POSTGRES_PASSWORD": "postgres",
1076                "POSTGRES_PORT": "5432",
1077                "POSTGRES_USER": "postgres"
1078                },
1079                "ports": [
1080                    {
1081                        "target": "5443",
1082                        "published": "5442"
1083                    },
1084                    {
1085                        "name": "custom port",
1086                        "protocol": "udp",
1087                        "host_ip": "127.0.0.1",
1088                        "app_protocol": "http",
1089                        "mode": "host",
1090                        "target": "8081",
1091                        "published": "8083"
1092
1093                    }
1094                ],
1095                "image": "mcr.microsoft.com/devcontainers/rust:2-1-bookworm",
1096                "network_mode": "service:db",
1097                "volumes": [
1098                {
1099                    "type": "bind",
1100                    "source": "/path/to",
1101                    "target": "/workspaces",
1102                    "bind": {
1103                    "create_host_path": true
1104                    }
1105                }
1106                ]
1107            },
1108            "db": {
1109                "command": null,
1110                "entrypoint": null,
1111                "environment": {
1112                "POSTGRES_DB": "postgres",
1113                "POSTGRES_HOSTNAME": "localhost",
1114                "POSTGRES_PASSWORD": "postgres",
1115                "POSTGRES_PORT": "5432",
1116                "POSTGRES_USER": "postgres"
1117                },
1118                "image": "postgres:14.1",
1119                "networks": {
1120                "default": null
1121                },
1122                "restart": "unless-stopped",
1123                "volumes": [
1124                {
1125                    "type": "volume",
1126                    "source": "postgres-data",
1127                    "target": "/var/lib/postgresql/data",
1128                    "volume": {}
1129                }
1130                ]
1131            }
1132        },
1133        "volumes": {
1134        "postgres-data": {
1135            "name": "devcontainer_postgres-data"
1136        }
1137        }
1138    }
1139                "#;
1140
1141        let docker_compose_config: DockerComposeConfig =
1142            serde_json_lenient::from_str(given_config).unwrap();
1143
1144        let expected_config = DockerComposeConfig {
1145            name: Some("devcontainer".to_string()),
1146            services: HashMap::from([
1147                (
1148                    "app".to_string(),
1149                    DockerComposeService {
1150                        command: vec!["sleep".to_string(), "infinity".to_string()],
1151                        image: Some(
1152                            "mcr.microsoft.com/devcontainers/rust:2-1-bookworm".to_string(),
1153                        ),
1154                        volumes: vec![MountDefinition {
1155                            mount_type: Some("bind".to_string()),
1156                            source: Some("/path/to".to_string()),
1157                            target: "/workspaces".to_string(),
1158                        }],
1159                        network_mode: Some("service:db".to_string()),
1160
1161                        ports: vec![
1162                            DockerComposeServicePort {
1163                                target: "5443".to_string(),
1164                                published: "5442".to_string(),
1165                                ..Default::default()
1166                            },
1167                            DockerComposeServicePort {
1168                                target: "8081".to_string(),
1169                                published: "8083".to_string(),
1170                                mode: Some("host".to_string()),
1171                                protocol: Some("udp".to_string()),
1172                                host_ip: Some("127.0.0.1".to_string()),
1173                                app_protocol: Some("http".to_string()),
1174                                name: Some("custom port".to_string()),
1175                            },
1176                        ],
1177                        environment: Some(HashMap::from([
1178                            ("POSTGRES_DB".to_string(), "postgres".to_string()),
1179                            ("POSTGRES_HOSTNAME".to_string(), "localhost".to_string()),
1180                            ("POSTGRES_PASSWORD".to_string(), "postgres".to_string()),
1181                            ("POSTGRES_PORT".to_string(), "5432".to_string()),
1182                            ("POSTGRES_USER".to_string(), "postgres".to_string()),
1183                        ])),
1184                        ..Default::default()
1185                    },
1186                ),
1187                (
1188                    "db".to_string(),
1189                    DockerComposeService {
1190                        image: Some("postgres:14.1".to_string()),
1191                        volumes: vec![MountDefinition {
1192                            mount_type: Some("volume".to_string()),
1193                            source: Some("postgres-data".to_string()),
1194                            target: "/var/lib/postgresql/data".to_string(),
1195                        }],
1196                        environment: Some(HashMap::from([
1197                            ("POSTGRES_DB".to_string(), "postgres".to_string()),
1198                            ("POSTGRES_HOSTNAME".to_string(), "localhost".to_string()),
1199                            ("POSTGRES_PASSWORD".to_string(), "postgres".to_string()),
1200                            ("POSTGRES_PORT".to_string(), "5432".to_string()),
1201                            ("POSTGRES_USER".to_string(), "postgres".to_string()),
1202                        ])),
1203                        ..Default::default()
1204                    },
1205                ),
1206            ]),
1207            volumes: HashMap::from([(
1208                "postgres-data".to_string(),
1209                DockerComposeVolume {
1210                    name: Some("devcontainer_postgres-data".to_string()),
1211                },
1212            )]),
1213        };
1214
1215        assert_eq!(docker_compose_config, expected_config);
1216    }
1217
1218    #[test]
1219    fn should_deserialize_compose_labels_as_map() {
1220        let given_config = r#"
1221        {
1222            "name": "devcontainer",
1223            "services": {
1224                "app": {
1225                    "image": "node:22-alpine",
1226                    "volumes": [],
1227                    "labels": {
1228                        "com.example.test": "value",
1229                        "another.label": "another-value"
1230                    }
1231                }
1232            }
1233        }
1234        "#;
1235
1236        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1237        let service = config.services.get("app").unwrap();
1238        let labels = service.labels.clone().unwrap();
1239        assert_eq!(
1240            labels,
1241            HashMap::from([
1242                ("another.label".to_string(), "another-value".to_string()),
1243                ("com.example.test".to_string(), "value".to_string())
1244            ])
1245        );
1246    }
1247
1248    #[test]
1249    fn should_deserialize_compose_labels_as_array() {
1250        let given_config = r#"
1251        {
1252            "name": "devcontainer",
1253            "services": {
1254                "app": {
1255                    "image": "node:22-alpine",
1256                    "volumes": [],
1257                    "labels": ["com.example.test=value"]
1258                }
1259            }
1260        }
1261        "#;
1262
1263        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1264        let service = config.services.get("app").unwrap();
1265        assert_eq!(
1266            service.labels,
1267            Some(HashMap::from([(
1268                "com.example.test".to_string(),
1269                "value".to_string()
1270            )]))
1271        );
1272    }
1273
1274    #[test]
1275    fn should_deserialize_compose_environment_key_only_entries() {
1276        let given_config = r#"
1277        {
1278            "name": "devcontainer",
1279            "services": {
1280                "array": {
1281                    "image": "node:22-alpine",
1282                    "environment": ["USER_INPUT", "DEFINED=value"]
1283                },
1284                "map": {
1285                    "image": "node:22-alpine",
1286                    "environment": {
1287                        "USER_INPUT": null,
1288                        "DEFINED": "value"
1289                    }
1290                }
1291            }
1292        }
1293        "#;
1294
1295        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1296        assert_eq!(
1297            config.services.get("array").unwrap().environment,
1298            Some(HashMap::from([
1299                ("DEFINED".to_string(), "value".to_string()),
1300                ("USER_INPUT".to_string(), "".to_string()),
1301            ]))
1302        );
1303        assert_eq!(
1304            config.services.get("map").unwrap().environment,
1305            Some(HashMap::from([(
1306                "DEFINED".to_string(),
1307                "value".to_string()
1308            )]))
1309        );
1310    }
1311
1312    #[test]
1313    fn should_deserialize_compose_without_volumes() {
1314        let given_config = r#"
1315        {
1316            "name": "devcontainer",
1317            "services": {
1318                "app": {
1319                    "image": "node:22-alpine",
1320                    "volumes": []
1321                }
1322            }
1323        }
1324        "#;
1325
1326        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1327        assert!(config.volumes.is_empty());
1328    }
1329
1330    #[test]
1331    fn should_deserialize_compose_with_missing_volumes_field() {
1332        let given_config = r#"
1333        {
1334            "name": "devcontainer",
1335            "services": {
1336                "sidecar": {
1337                    "image": "ubuntu:24.04"
1338                }
1339            }
1340        }
1341        "#;
1342
1343        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1344        let service = config.services.get("sidecar").unwrap();
1345        assert!(service.volumes.is_empty());
1346    }
1347
1348    #[test]
1349    fn should_deserialize_compose_volume_without_source() {
1350        let given_config = r#"
1351        {
1352            "name": "devcontainer",
1353            "services": {
1354                "app": {
1355                    "image": "ubuntu:24.04",
1356                    "volumes": [
1357                        {
1358                            "type": "tmpfs",
1359                            "target": "/tmp"
1360                        }
1361                    ]
1362                }
1363            }
1364        }
1365        "#;
1366
1367        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1368        let service = config.services.get("app").unwrap();
1369        assert_eq!(service.volumes.len(), 1);
1370        assert_eq!(service.volumes[0].source, None);
1371        assert_eq!(service.volumes[0].target, "/tmp");
1372        assert_eq!(service.volumes[0].mount_type, Some("tmpfs".to_string()));
1373    }
1374
1375    #[test]
1376    fn should_deserialize_compose_inline_volume_strings() {
1377        let given_yaml = indoc::indoc! {r#"
1378            name: devcontainer
1379            services:
1380              app:
1381                image: node:18
1382                volumes:
1383                  - postgres-data:/var/lib/postgresql/data
1384                  - /host/path:/container/path
1385                  - /anonymous/volume
1386                  - type: bind
1387                    source: /explicit
1388                    target: /mnt/explicit
1389            volumes:
1390              postgres-data:
1391                name: devcontainer_postgres-data
1392        "#};
1393
1394        let config: DockerComposeConfig = serde_yaml::from_str(given_yaml).unwrap();
1395        let service = config.services.get("app").unwrap();
1396        assert_eq!(service.volumes.len(), 4);
1397
1398        assert_eq!(service.volumes[0].source, Some("postgres-data".to_string()));
1399        assert_eq!(service.volumes[0].target, "/var/lib/postgresql/data");
1400        assert_eq!(service.volumes[0].mount_type, None);
1401
1402        assert_eq!(service.volumes[1].source, Some("/host/path".to_string()));
1403        assert_eq!(service.volumes[1].target, "/container/path");
1404
1405        assert_eq!(service.volumes[2].source, None);
1406        assert_eq!(service.volumes[2].target, "/anonymous/volume");
1407
1408        assert_eq!(service.volumes[3].source, Some("/explicit".to_string()));
1409        assert_eq!(service.volumes[3].target, "/mnt/explicit");
1410        assert_eq!(service.volumes[3].mount_type, Some("bind".to_string()));
1411    }
1412
1413    #[test]
1414    fn should_deserialize_compose_top_level_volumes_with_null_value() {
1415        let given_yaml = indoc::indoc! {r#"
1416            name: devcontainer
1417            services:
1418              app:
1419                image: node:18
1420            volumes:
1421              postgres-data:
1422              named-vol:
1423                name: custom-name
1424        "#};
1425
1426        let config: DockerComposeConfig = serde_yaml::from_str(given_yaml).unwrap();
1427        assert_eq!(config.volumes.len(), 2);
1428
1429        let bare = config
1430            .volumes
1431            .get("postgres-data")
1432            .expect("bare volume should exist");
1433        assert_eq!(bare.name, None);
1434
1435        let named = config
1436            .volumes
1437            .get("named-vol")
1438            .expect("named volume should exist");
1439        assert_eq!(named.name, Some("custom-name".to_string()));
1440    }
1441
1442    #[test]
1443    fn should_deserialize_inspect_without_labels() {
1444        let given_config = r#"
1445        {
1446            "Id": "sha256:abc123",
1447            "Config": {
1448                "Env": ["PATH=/usr/bin"],
1449                "Cmd": ["node"],
1450                "WorkingDir": "/"
1451            }
1452        }
1453        "#;
1454
1455        let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1456        assert!(inspect.config.labels.metadata.is_none());
1457        assert!(inspect.config.image_user.is_none());
1458    }
1459
1460    #[test]
1461    fn should_deserialize_inspect_with_null_labels() {
1462        let given_config = r#"
1463        {
1464            "Id": "sha256:abc123",
1465            "Config": {
1466                "Labels": null,
1467                "Env": ["PATH=/usr/bin"]
1468            }
1469        }
1470        "#;
1471
1472        let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1473        assert!(inspect.config.labels.metadata.is_none());
1474    }
1475
1476    #[test]
1477    fn should_deserialize_inspect_with_labels_but_no_metadata() {
1478        let given_config = r#"
1479        {
1480            "Id": "sha256:abc123",
1481            "Config": {
1482                "Labels": {
1483                    "com.example.test": "value"
1484                },
1485                "Env": ["PATH=/usr/bin"]
1486            }
1487        }
1488        "#;
1489
1490        let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1491        assert!(inspect.config.labels.metadata.is_none());
1492    }
1493}
1494
Served at tenant.openagents/omega Member data and write actions are omitted.