Skip to repository content1494 lines · 53.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:34:12.986Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
docker.rs
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