1use std::{collections::HashMap, fmt::Display, path::Path, sync::Arc};
2
3use crate::{command_json::CommandRunner, devcontainer_api::DevContainerError};
4use serde::{Deserialize, Deserializer, Serialize};
5use serde_json_lenient::Value;
6use util::command::Command;
7
8#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)]
9#[serde(untagged)]
10pub(crate) enum ForwardPort {
11 Number(u16),
12 String(String),
13}
14
15#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
16#[serde(rename_all = "camelCase")]
17pub(crate) enum PortAttributeProtocol {
18 Https,
19 Http,
20}
21
22#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
23#[serde(rename_all = "camelCase")]
24pub(crate) enum OnAutoForward {
25 Notify,
26 OpenBrowser,
27 OpenBrowserOnce,
28 OpenPreview,
29 Silent,
30 Ignore,
31}
32
33#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
34#[serde(rename_all = "camelCase")]
35pub(crate) struct PortAttributes {
36 label: String,
37 on_auto_forward: OnAutoForward,
38 elevate_if_needed: bool,
39 require_local_port: bool,
40 protocol: PortAttributeProtocol,
41}
42
43#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
44#[serde(rename_all = "camelCase")]
45pub(crate) enum UserEnvProbe {
46 None,
47 InteractiveShell,
48 LoginShell,
49 LoginInteractiveShell,
50}
51
52#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
53#[serde(rename_all = "camelCase")]
54pub(crate) enum ShutdownAction {
55 None,
56 StopContainer,
57 StopCompose,
58}
59
60#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
61#[serde(rename_all = "camelCase")]
62pub(crate) struct MountDefinition {
63 #[serde(default)]
64 pub(crate) source: Option<String>,
65 pub(crate) target: String,
66 #[serde(rename = "type")]
67 pub(crate) mount_type: Option<String>,
68}
69
70impl Display for MountDefinition {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 let mount_type = self.mount_type.clone().unwrap_or_else(|| {
73 if let Some(source) = &self.source {
74 if source.starts_with('/')
75 || source.starts_with("\\\\")
76 || source.get(1..3) == Some(":\\")
77 || source.get(1..3) == Some(":/")
78 {
79 return "bind".to_string();
80 }
81 }
82 "volume".to_string()
83 });
84 write!(f, "type={}", mount_type)?;
85 if let Some(source) = &self.source {
86 write!(f, ",source={}", source)?;
87 }
88 write!(f, ",target={},consistency=cached", self.target)
89 }
90}
91
92/// Represents the value associated with a feature ID in the `features` map of devcontainer.json.
93///
94/// Per the spec, the value can be:
95/// - A boolean (`true` to enable with defaults)
96/// - A string (shorthand for `{"version": "<value>"}`)
97/// - An object mapping option names to string or boolean values
98///
99/// See: https://containers.dev/implementors/features/#devcontainerjson-properties
100#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)]
101#[serde(untagged)]
102pub(crate) enum FeatureOptions {
103 Bool(bool),
104 String(String),
105 Options(HashMap<String, FeatureOptionValue>),
106}
107
108#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)]
109#[serde(untagged)]
110pub(crate) enum FeatureOptionValue {
111 Bool(bool),
112 String(String),
113}
114impl std::fmt::Display for FeatureOptionValue {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 match self {
117 FeatureOptionValue::Bool(b) => write!(f, "{}", b),
118 FeatureOptionValue::String(s) => write!(f, "{}", s),
119 }
120 }
121}
122
123#[derive(Clone, Debug, Serialize, Eq, PartialEq, Default)]
124pub(crate) struct ZedCustomizationsWrapper {
125 pub(crate) zed: ZedCustomization,
126}
127
128#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Default)]
129pub(crate) struct ZedCustomization {
130 #[serde(default)]
131 pub(crate) extensions: Vec<String>,
132}
133
134#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
135#[serde(rename_all = "camelCase")]
136pub(crate) struct ContainerBuild {
137 pub(crate) dockerfile: String,
138 context: Option<String>,
139 pub(crate) args: Option<HashMap<String, String>>,
140 options: Option<Vec<String>>,
141 target: Option<String>,
142 #[serde(default, deserialize_with = "deserialize_string_or_array")]
143 cache_from: Option<Vec<String>>,
144}
145
146#[derive(Clone, Debug, Serialize, Eq, PartialEq)]
147struct LifecycleScriptInternal {
148 command: Option<String>,
149 args: Vec<String>,
150}
151
152impl LifecycleScriptInternal {
153 fn from_args(args: Vec<String>) -> Self {
154 let command = args.get(0).map(|a| a.to_string());
155 let remaining = args.iter().skip(1).map(|a| a.to_string()).collect();
156 Self {
157 command,
158 args: remaining,
159 }
160 }
161}
162
163#[derive(Clone, Debug, Serialize, Eq, PartialEq)]
164pub struct LifecycleScript {
165 scripts: HashMap<String, LifecycleScriptInternal>,
166}
167
168#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
169#[serde(rename_all = "camelCase")]
170pub(crate) struct HostRequirements {
171 cpus: Option<u16>,
172 memory: Option<String>,
173 storage: Option<String>,
174}
175
176#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
177#[serde(rename_all = "camelCase")]
178pub(crate) enum LifecycleCommand {
179 InitializeCommand,
180 OnCreateCommand,
181 UpdateContentCommand,
182 PostCreateCommand,
183 PostStartCommand,
184}
185
186#[derive(Debug, PartialEq, Eq)]
187pub(crate) enum DevContainerBuildType {
188 Image,
189 Dockerfile,
190 DockerCompose,
191 None,
192}
193#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Default)]
194#[serde(rename_all = "camelCase")]
195pub(crate) struct DevContainer {
196 pub(crate) image: Option<String>,
197 pub(crate) name: Option<String>,
198 pub(crate) remote_user: Option<String>,
199 pub(crate) forward_ports: Option<Vec<ForwardPort>>,
200 pub(crate) ports_attributes: Option<HashMap<String, PortAttributes>>,
201 pub(crate) other_ports_attributes: Option<PortAttributes>,
202 pub(crate) container_env: Option<HashMap<String, String>>,
203 pub(crate) remote_env: Option<HashMap<String, String>>,
204 pub(crate) container_user: Option<String>,
205 #[serde(rename = "updateRemoteUserUID")]
206 pub(crate) update_remote_user_uid: Option<bool>,
207 user_env_probe: Option<UserEnvProbe>,
208 override_command: Option<bool>,
209 shutdown_action: Option<ShutdownAction>,
210 init: Option<bool>,
211 pub(crate) privileged: Option<bool>,
212 cap_add: Option<Vec<String>>,
213 security_opt: Option<Vec<String>>,
214 #[serde(default, deserialize_with = "deserialize_mount_definitions")]
215 pub(crate) mounts: Option<Vec<MountDefinition>>,
216 pub(crate) features: Option<HashMap<String, FeatureOptions>>,
217 pub(crate) override_feature_install_order: Option<Vec<String>>,
218 pub(crate) customizations: Option<ZedCustomizationsWrapper>,
219 pub(crate) build: Option<ContainerBuild>,
220 #[serde(default, deserialize_with = "deserialize_app_port")]
221 pub(crate) app_port: Vec<String>,
222 #[serde(default, deserialize_with = "deserialize_mount_definition")]
223 pub(crate) workspace_mount: Option<MountDefinition>,
224 pub(crate) workspace_folder: Option<String>,
225 run_args: Option<Vec<String>>,
226 #[serde(default, deserialize_with = "deserialize_string_or_array")]
227 pub(crate) docker_compose_file: Option<Vec<String>>,
228 pub(crate) service: Option<String>,
229 run_services: Option<Vec<String>>,
230 pub(crate) initialize_command: Option<LifecycleScript>,
231 pub(crate) on_create_command: Option<LifecycleScript>,
232 pub(crate) update_content_command: Option<LifecycleScript>,
233 pub(crate) post_create_command: Option<LifecycleScript>,
234 pub(crate) post_start_command: Option<LifecycleScript>,
235 pub(crate) post_attach_command: Option<LifecycleScript>,
236 wait_for: Option<LifecycleCommand>,
237 host_requirements: Option<HostRequirements>,
238}
239
240pub(crate) fn deserialize_devcontainer_json(json: &str) -> Result<DevContainer, DevContainerError> {
241 match serde_json_lenient::from_str(json) {
242 Ok(devcontainer) => Ok(devcontainer),
243 Err(e) => {
244 log::error!("Unable to deserialize devcontainer from json: {e}");
245 Err(DevContainerError::DevContainerParseFailed)
246 }
247 }
248}
249
250impl DevContainer {
251 pub(crate) fn build_type(&self) -> DevContainerBuildType {
252 if self.image.is_some() {
253 return DevContainerBuildType::Image;
254 } else if self.docker_compose_file.is_some() {
255 return DevContainerBuildType::DockerCompose;
256 } else if self.build.is_some() {
257 return DevContainerBuildType::Dockerfile;
258 }
259 return DevContainerBuildType::None;
260 }
261}
262
263// Custom deserializer that parses the entire customizations object as a
264// serde_json_lenient::Value first, then extracts the "zed" portion.
265// This avoids a bug in serde_json_lenient's `ignore_value` codepath which
266// does not handle trailing commas in skipped values.
267impl<'de> Deserialize<'de> for ZedCustomizationsWrapper {
268 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
269 where
270 D: Deserializer<'de>,
271 {
272 let value = Value::deserialize(deserializer)?;
273 let zed = value
274 .get("zed")
275 .map(|zed_value| serde_json_lenient::from_value::<ZedCustomization>(zed_value.clone()))
276 .transpose()
277 .map_err(serde::de::Error::custom)?
278 .unwrap_or_default();
279 Ok(ZedCustomizationsWrapper { zed })
280 }
281}
282
283impl LifecycleScript {
284 fn from_map(args: HashMap<String, Vec<String>>) -> Self {
285 Self {
286 scripts: args
287 .into_iter()
288 .map(|(k, v)| (k, LifecycleScriptInternal::from_args(v)))
289 .collect(),
290 }
291 }
292 fn from_str(args: &str) -> Self {
293 let script: Vec<String> = args.split(" ").map(|a| a.to_string()).collect();
294
295 Self::from_args(script)
296 }
297 fn from_args(args: Vec<String>) -> Self {
298 Self::from_map(HashMap::from([("default".to_string(), args)]))
299 }
300 pub fn script_commands(&self) -> HashMap<String, Command> {
301 self.scripts
302 .iter()
303 .filter_map(|(k, v)| {
304 if let Some(inner_command) = &v.command {
305 let mut command = Command::new(inner_command);
306 command.args(&v.args);
307 Some((k.clone(), command))
308 } else {
309 log::warn!(
310 "Lifecycle script command {k}, value {:?} has no program to run. Skipping",
311 v
312 );
313 None
314 }
315 })
316 .collect()
317 }
318
319 pub async fn run(
320 &self,
321 command_runnder: &Arc<dyn CommandRunner>,
322 working_directory: &Path,
323 ) -> Result<(), DevContainerError> {
324 for (command_name, mut command) in self.script_commands() {
325 log::debug!("Running script {command_name}");
326
327 command.current_dir(working_directory);
328
329 let output = command_runnder
330 .run_command(&mut command)
331 .await
332 .map_err(|e| {
333 log::error!("Error running command {command_name}: {e}");
334 DevContainerError::CommandFailed(command_name.clone())
335 })?;
336 if !output.status.success() {
337 let std_err = String::from_utf8_lossy(&output.stderr);
338 log::error!(
339 "Command {command_name} produced a non-successful output. StdErr: {std_err}"
340 );
341 }
342 let std_out = String::from_utf8_lossy(&output.stdout);
343 log::debug!("Command {command_name} output:\n {std_out}");
344 }
345 Ok(())
346 }
347}
348
349impl<'de> Deserialize<'de> for LifecycleScript {
350 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
351 where
352 D: Deserializer<'de>,
353 {
354 use serde::de::{self, Visitor};
355 use std::fmt;
356
357 struct LifecycleScriptVisitor;
358
359 impl<'de> Visitor<'de> for LifecycleScriptVisitor {
360 type Value = LifecycleScript;
361
362 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
363 formatter.write_str("a string, an array of strings, or a map of arrays")
364 }
365
366 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
367 where
368 E: de::Error,
369 {
370 Ok(LifecycleScript::from_str(value))
371 }
372
373 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
374 where
375 A: de::SeqAccess<'de>,
376 {
377 let mut array = Vec::new();
378 while let Some(elem) = seq.next_element()? {
379 array.push(elem);
380 }
381 Ok(LifecycleScript::from_args(array))
382 }
383
384 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
385 where
386 A: de::MapAccess<'de>,
387 {
388 let mut result = HashMap::new();
389 while let Some(key) = map.next_key::<String>()? {
390 let value: Value = map.next_value()?;
391 let script_args = match value {
392 Value::String(s) => {
393 s.split(" ").map(|s| s.to_string()).collect::<Vec<String>>()
394 }
395 Value::Array(arr) => {
396 let strings: Vec<String> = arr
397 .into_iter()
398 .filter_map(|v| v.as_str().map(|s| s.to_string()))
399 .collect();
400 strings
401 }
402 _ => continue,
403 };
404 result.insert(key, script_args);
405 }
406 Ok(LifecycleScript::from_map(result))
407 }
408 }
409
410 deserializer.deserialize_any(LifecycleScriptVisitor)
411 }
412}
413
414fn deserialize_mount_definition<'de, D>(
415 deserializer: D,
416) -> Result<Option<MountDefinition>, D::Error>
417where
418 D: serde::Deserializer<'de>,
419{
420 use serde::Deserialize;
421 use serde::de::Error;
422
423 #[derive(Deserialize)]
424 #[serde(untagged)]
425 enum MountItem {
426 Object(MountDefinition),
427 String(String),
428 }
429
430 let item = MountItem::deserialize(deserializer)?;
431
432 let mount = match item {
433 MountItem::Object(mount) => mount,
434 MountItem::String(s) => {
435 let mut source = None;
436 let mut target = None;
437 let mut mount_type = None;
438
439 for part in s.split(',') {
440 let part = part.trim();
441 if let Some((key, value)) = part.split_once('=') {
442 match key.trim() {
443 "source" => source = Some(value.trim().to_string()),
444 "target" => target = Some(value.trim().to_string()),
445 "type" => mount_type = Some(value.trim().to_string()),
446 _ => {} // Ignore unknown keys
447 }
448 }
449 }
450
451 let target = target
452 .ok_or_else(|| D::Error::custom(format!("mount string missing 'target': {}", s)))?;
453
454 MountDefinition {
455 source,
456 target,
457 mount_type,
458 }
459 }
460 };
461
462 Ok(Some(mount))
463}
464
465fn deserialize_mount_definitions<'de, D>(
466 deserializer: D,
467) -> Result<Option<Vec<MountDefinition>>, D::Error>
468where
469 D: serde::Deserializer<'de>,
470{
471 use serde::Deserialize;
472 use serde::de::Error;
473
474 #[derive(Deserialize)]
475 #[serde(untagged)]
476 enum MountItem {
477 Object(MountDefinition),
478 String(String),
479 }
480
481 let items = Vec::<MountItem>::deserialize(deserializer)?;
482 let mut mounts = Vec::new();
483
484 for item in items {
485 match item {
486 MountItem::Object(mount) => mounts.push(mount),
487 MountItem::String(s) => {
488 let mut source = None;
489 let mut target = None;
490 let mut mount_type = None;
491
492 for part in s.split(',') {
493 let part = part.trim();
494 if let Some((key, value)) = part.split_once('=') {
495 match key.trim() {
496 "source" => source = Some(value.trim().to_string()),
497 "target" => target = Some(value.trim().to_string()),
498 "type" => mount_type = Some(value.trim().to_string()),
499 _ => {} // Ignore unknown keys
500 }
501 }
502 }
503
504 let target = target.ok_or_else(|| {
505 D::Error::custom(format!("mount string missing 'target': {}", s))
506 })?;
507
508 mounts.push(MountDefinition {
509 source,
510 target,
511 mount_type,
512 });
513 }
514 }
515 }
516
517 Ok(Some(mounts))
518}
519
520fn deserialize_app_port<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
521where
522 D: serde::Deserializer<'de>,
523{
524 use serde::Deserialize;
525
526 #[derive(Deserialize)]
527 #[serde(untagged)]
528 enum StringOrInt {
529 String(String),
530 Int(u32),
531 }
532
533 #[derive(Deserialize)]
534 #[serde(untagged)]
535 enum AppPort {
536 Array(Vec<StringOrInt>),
537 Single(StringOrInt),
538 }
539
540 fn normalize_port(value: StringOrInt) -> String {
541 match value {
542 StringOrInt::String(s) => {
543 if s.contains(':') {
544 s
545 } else {
546 format!("{s}:{s}")
547 }
548 }
549 StringOrInt::Int(n) => format!("{n}:{n}"),
550 }
551 }
552
553 match AppPort::deserialize(deserializer)? {
554 AppPort::Single(value) => Ok(vec![normalize_port(value)]),
555 AppPort::Array(values) => Ok(values.into_iter().map(normalize_port).collect()),
556 }
557}
558
559fn deserialize_string_or_array<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
560where
561 D: serde::Deserializer<'de>,
562{
563 use serde::Deserialize;
564
565 #[derive(Deserialize)]
566 #[serde(untagged)]
567 enum StringOrArray {
568 String(String),
569 Array(Vec<String>),
570 }
571
572 match StringOrArray::deserialize(deserializer)? {
573 StringOrArray::String(s) => Ok(Some(vec![s])),
574 StringOrArray::Array(b) => Ok(Some(b)),
575 }
576}
577
578#[cfg(test)]
579mod test {
580 use std::collections::HashMap;
581
582 use crate::{
583 devcontainer_api::DevContainerError,
584 devcontainer_json::{
585 ContainerBuild, DevContainer, DevContainerBuildType, FeatureOptions, ForwardPort,
586 HostRequirements, LifecycleCommand, LifecycleScript, MountDefinition, OnAutoForward,
587 PortAttributeProtocol, PortAttributes, ShutdownAction, UserEnvProbe, ZedCustomization,
588 ZedCustomizationsWrapper, deserialize_devcontainer_json,
589 },
590 };
591
592 #[test]
593 fn should_deserialize_customizations_with_unknown_keys() {
594 let json_with_other_customizations = r#"
595 {
596 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
597 "customizations": {
598 "vscode": {
599 "extensions": [
600 "dbaeumer.vscode-eslint",
601 "GitHub.vscode-pull-request-github",
602 ],
603 },
604 "zed": {
605 "extensions": ["vue", "ruby"],
606 },
607 "codespaces": {
608 "repositories": {
609 "devcontainers/features": {
610 "permissions": {
611 "contents": "write",
612 "workflows": "write",
613 },
614 },
615 },
616 },
617 },
618 }
619 "#;
620
621 let result = deserialize_devcontainer_json(json_with_other_customizations);
622
623 assert!(
624 result.is_ok(),
625 "Should ignore unknown customization keys, but got: {:?}",
626 result.err()
627 );
628 let devcontainer = result.expect("ok");
629 assert_eq!(
630 devcontainer.customizations,
631 Some(ZedCustomizationsWrapper {
632 zed: ZedCustomization {
633 extensions: vec!["vue".to_string(), "ruby".to_string()]
634 }
635 })
636 );
637 }
638
639 #[test]
640 fn should_deserialize_customizations_without_zed_key() {
641 let json_without_zed = r#"
642 {
643 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
644 "customizations": {
645 "vscode": {
646 "extensions": ["dbaeumer.vscode-eslint"]
647 }
648 }
649 }
650 "#;
651
652 let result = deserialize_devcontainer_json(json_without_zed);
653
654 assert!(
655 result.is_ok(),
656 "Should handle missing zed key in customizations, but got: {:?}",
657 result.err()
658 );
659 let devcontainer = result.expect("ok");
660 assert_eq!(
661 devcontainer.customizations,
662 Some(ZedCustomizationsWrapper {
663 zed: ZedCustomization { extensions: vec![] }
664 })
665 );
666 }
667
668 #[test]
669 fn should_deserialize_simple_devcontainer_json() {
670 let given_bad_json = "{ \"image\": 123 }";
671
672 let result = deserialize_devcontainer_json(given_bad_json);
673
674 assert!(result.is_err());
675 assert_eq!(
676 result.expect_err("err"),
677 DevContainerError::DevContainerParseFailed
678 );
679
680 let given_image_container_json = r#"
681 // These are some external comments. serde_lenient should handle them
682 {
683 // These are some internal comments
684 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
685 "name": "myDevContainer",
686 "remoteUser": "root",
687 "forwardPorts": [
688 "db:5432",
689 3000
690 ],
691 "portsAttributes": {
692 "3000": {
693 "label": "This Port",
694 "onAutoForward": "notify",
695 "elevateIfNeeded": false,
696 "requireLocalPort": true,
697 "protocol": "https"
698 },
699 "db:5432": {
700 "label": "This Port too",
701 "onAutoForward": "silent",
702 "elevateIfNeeded": true,
703 "requireLocalPort": false,
704 "protocol": "http"
705 }
706 },
707 "otherPortsAttributes": {
708 "label": "Other Ports",
709 "onAutoForward": "openBrowser",
710 "elevateIfNeeded": true,
711 "requireLocalPort": true,
712 "protocol": "https"
713 },
714 "updateRemoteUserUID": true,
715 "remoteEnv": {
716 "MYVAR1": "myvarvalue",
717 "MYVAR2": "myvarothervalue"
718 },
719 "initializeCommand": ["echo", "initialize_command"],
720 "onCreateCommand": "echo on_create_command",
721 "updateContentCommand": {
722 "first": "echo update_content_command",
723 "second": ["echo", "update_content_command"]
724 },
725 "postCreateCommand": ["echo", "post_create_command"],
726 "postStartCommand": "echo post_start_command",
727 "postAttachCommand": {
728 "something": "echo post_attach_command",
729 "something1": "echo something else",
730 },
731 "waitFor": "postStartCommand",
732 "userEnvProbe": "loginShell",
733 "features": {
734 "ghcr.io/devcontainers/features/aws-cli:1": {},
735 "ghcr.io/devcontainers/features/anaconda:1": {}
736 },
737 "overrideFeatureInstallOrder": [
738 "ghcr.io/devcontainers/features/anaconda:1",
739 "ghcr.io/devcontainers/features/aws-cli:1"
740 ],
741 "hostRequirements": {
742 "cpus": 2,
743 "memory": "8gb",
744 "storage": "32gb",
745 // Note that we're not parsing this currently
746 "gpu": true,
747 },
748 "appPort": 8081,
749 "containerEnv": {
750 "MYVAR3": "myvar3",
751 "MYVAR4": "myvar4"
752 },
753 "containerUser": "myUser",
754 "mounts": [
755 {
756 "source": "/localfolder/app",
757 "target": "/workspaces/app",
758 "type": "volume"
759 }
760 ],
761 "runArgs": [
762 "-c",
763 "some_command"
764 ],
765 "shutdownAction": "stopContainer",
766 "overrideCommand": true,
767 "workspaceFolder": "/workspaces",
768 "workspaceMount": "source=/app,target=/workspaces/app,type=bind,consistency=cached",
769 "customizations": {
770 "vscode": {
771 // Just confirm that this can be included and ignored
772 },
773 "zed": {
774 "extensions": [
775 "html"
776 ]
777 }
778 }
779 }
780 "#;
781
782 let result = deserialize_devcontainer_json(given_image_container_json);
783
784 assert!(result.is_ok());
785 let devcontainer = result.expect("ok");
786 assert_eq!(
787 devcontainer,
788 DevContainer {
789 image: Some(String::from("mcr.microsoft.com/devcontainers/base:ubuntu")),
790 name: Some(String::from("myDevContainer")),
791 remote_user: Some(String::from("root")),
792 forward_ports: Some(vec![
793 ForwardPort::String("db:5432".to_string()),
794 ForwardPort::Number(3000),
795 ]),
796 ports_attributes: Some(HashMap::from([
797 (
798 "3000".to_string(),
799 PortAttributes {
800 label: "This Port".to_string(),
801 on_auto_forward: OnAutoForward::Notify,
802 elevate_if_needed: false,
803 require_local_port: true,
804 protocol: PortAttributeProtocol::Https
805 }
806 ),
807 (
808 "db:5432".to_string(),
809 PortAttributes {
810 label: "This Port too".to_string(),
811 on_auto_forward: OnAutoForward::Silent,
812 elevate_if_needed: true,
813 require_local_port: false,
814 protocol: PortAttributeProtocol::Http
815 }
816 )
817 ])),
818 other_ports_attributes: Some(PortAttributes {
819 label: "Other Ports".to_string(),
820 on_auto_forward: OnAutoForward::OpenBrowser,
821 elevate_if_needed: true,
822 require_local_port: true,
823 protocol: PortAttributeProtocol::Https
824 }),
825 update_remote_user_uid: Some(true),
826 remote_env: Some(HashMap::from([
827 ("MYVAR1".to_string(), "myvarvalue".to_string()),
828 ("MYVAR2".to_string(), "myvarothervalue".to_string())
829 ])),
830 initialize_command: Some(LifecycleScript::from_args(vec![
831 "echo".to_string(),
832 "initialize_command".to_string()
833 ])),
834 on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
835 update_content_command: Some(LifecycleScript::from_map(HashMap::from([
836 (
837 "first".to_string(),
838 vec!["echo".to_string(), "update_content_command".to_string()]
839 ),
840 (
841 "second".to_string(),
842 vec!["echo".to_string(), "update_content_command".to_string()]
843 )
844 ]))),
845 post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
846 post_start_command: Some(LifecycleScript::from_args(vec![
847 "echo".to_string(),
848 "post_start_command".to_string()
849 ])),
850 post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
851 (
852 "something".to_string(),
853 vec!["echo".to_string(), "post_attach_command".to_string()]
854 ),
855 (
856 "something1".to_string(),
857 vec![
858 "echo".to_string(),
859 "something".to_string(),
860 "else".to_string()
861 ]
862 )
863 ]))),
864 wait_for: Some(LifecycleCommand::PostStartCommand),
865 user_env_probe: Some(UserEnvProbe::LoginShell),
866 features: Some(HashMap::from([
867 (
868 "ghcr.io/devcontainers/features/aws-cli:1".to_string(),
869 FeatureOptions::Options(HashMap::new())
870 ),
871 (
872 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
873 FeatureOptions::Options(HashMap::new())
874 )
875 ])),
876 override_feature_install_order: Some(vec![
877 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
878 "ghcr.io/devcontainers/features/aws-cli:1".to_string()
879 ]),
880 host_requirements: Some(HostRequirements {
881 cpus: Some(2),
882 memory: Some("8gb".to_string()),
883 storage: Some("32gb".to_string()),
884 }),
885 app_port: vec!["8081:8081".to_string()],
886 container_env: Some(HashMap::from([
887 ("MYVAR3".to_string(), "myvar3".to_string()),
888 ("MYVAR4".to_string(), "myvar4".to_string())
889 ])),
890 container_user: Some("myUser".to_string()),
891 mounts: Some(vec![MountDefinition {
892 source: Some("/localfolder/app".to_string()),
893 target: "/workspaces/app".to_string(),
894 mount_type: Some("volume".to_string()),
895 }]),
896 run_args: Some(vec!["-c".to_string(), "some_command".to_string()]),
897 shutdown_action: Some(ShutdownAction::StopContainer),
898 override_command: Some(true),
899 workspace_folder: Some("/workspaces".to_string()),
900 workspace_mount: Some(MountDefinition {
901 source: Some("/app".to_string()),
902 target: "/workspaces/app".to_string(),
903 mount_type: Some("bind".to_string())
904 }),
905 customizations: Some(ZedCustomizationsWrapper {
906 zed: ZedCustomization {
907 extensions: vec!["html".to_string()]
908 }
909 }),
910 ..Default::default()
911 }
912 );
913
914 assert_eq!(devcontainer.build_type(), DevContainerBuildType::Image);
915 }
916
917 #[test]
918 fn should_deserialize_docker_compose_devcontainer_json() {
919 let given_docker_compose_json = r#"
920 // These are some external comments. serde_lenient should handle them
921 {
922 // These are some internal comments
923 "name": "myDevContainer",
924 "remoteUser": "root",
925 "forwardPorts": [
926 "db:5432",
927 3000
928 ],
929 "portsAttributes": {
930 "3000": {
931 "label": "This Port",
932 "onAutoForward": "notify",
933 "elevateIfNeeded": false,
934 "requireLocalPort": true,
935 "protocol": "https"
936 },
937 "db:5432": {
938 "label": "This Port too",
939 "onAutoForward": "silent",
940 "elevateIfNeeded": true,
941 "requireLocalPort": false,
942 "protocol": "http"
943 }
944 },
945 "otherPortsAttributes": {
946 "label": "Other Ports",
947 "onAutoForward": "openBrowser",
948 "elevateIfNeeded": true,
949 "requireLocalPort": true,
950 "protocol": "https"
951 },
952 "updateRemoteUserUID": true,
953 "remoteEnv": {
954 "MYVAR1": "myvarvalue",
955 "MYVAR2": "myvarothervalue"
956 },
957 "initializeCommand": ["echo", "initialize_command"],
958 "onCreateCommand": "echo on_create_command",
959 "updateContentCommand": {
960 "first": "echo update_content_command",
961 "second": ["echo", "update_content_command"]
962 },
963 "postCreateCommand": ["echo", "post_create_command"],
964 "postStartCommand": "echo post_start_command",
965 "postAttachCommand": {
966 "something": "echo post_attach_command",
967 "something1": "echo something else",
968 },
969 "waitFor": "postStartCommand",
970 "userEnvProbe": "loginShell",
971 "features": {
972 "ghcr.io/devcontainers/features/aws-cli:1": {},
973 "ghcr.io/devcontainers/features/anaconda:1": {}
974 },
975 "overrideFeatureInstallOrder": [
976 "ghcr.io/devcontainers/features/anaconda:1",
977 "ghcr.io/devcontainers/features/aws-cli:1"
978 ],
979 "hostRequirements": {
980 "cpus": 2,
981 "memory": "8gb",
982 "storage": "32gb",
983 // Note that we're not parsing this currently
984 "gpu": true,
985 },
986 "dockerComposeFile": "docker-compose.yml",
987 "service": "myService",
988 "runServices": [
989 "myService",
990 "mySupportingService"
991 ],
992 "workspaceFolder": "/workspaces/thing",
993 "shutdownAction": "stopCompose",
994 "overrideCommand": true
995 }
996 "#;
997 let result = deserialize_devcontainer_json(given_docker_compose_json);
998
999 assert!(result.is_ok());
1000 let devcontainer = result.expect("ok");
1001 assert_eq!(
1002 devcontainer,
1003 DevContainer {
1004 name: Some(String::from("myDevContainer")),
1005 remote_user: Some(String::from("root")),
1006 forward_ports: Some(vec![
1007 ForwardPort::String("db:5432".to_string()),
1008 ForwardPort::Number(3000),
1009 ]),
1010 ports_attributes: Some(HashMap::from([
1011 (
1012 "3000".to_string(),
1013 PortAttributes {
1014 label: "This Port".to_string(),
1015 on_auto_forward: OnAutoForward::Notify,
1016 elevate_if_needed: false,
1017 require_local_port: true,
1018 protocol: PortAttributeProtocol::Https
1019 }
1020 ),
1021 (
1022 "db:5432".to_string(),
1023 PortAttributes {
1024 label: "This Port too".to_string(),
1025 on_auto_forward: OnAutoForward::Silent,
1026 elevate_if_needed: true,
1027 require_local_port: false,
1028 protocol: PortAttributeProtocol::Http
1029 }
1030 )
1031 ])),
1032 other_ports_attributes: Some(PortAttributes {
1033 label: "Other Ports".to_string(),
1034 on_auto_forward: OnAutoForward::OpenBrowser,
1035 elevate_if_needed: true,
1036 require_local_port: true,
1037 protocol: PortAttributeProtocol::Https
1038 }),
1039 update_remote_user_uid: Some(true),
1040 remote_env: Some(HashMap::from([
1041 ("MYVAR1".to_string(), "myvarvalue".to_string()),
1042 ("MYVAR2".to_string(), "myvarothervalue".to_string())
1043 ])),
1044 initialize_command: Some(LifecycleScript::from_args(vec![
1045 "echo".to_string(),
1046 "initialize_command".to_string()
1047 ])),
1048 on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
1049 update_content_command: Some(LifecycleScript::from_map(HashMap::from([
1050 (
1051 "first".to_string(),
1052 vec!["echo".to_string(), "update_content_command".to_string()]
1053 ),
1054 (
1055 "second".to_string(),
1056 vec!["echo".to_string(), "update_content_command".to_string()]
1057 )
1058 ]))),
1059 post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
1060 post_start_command: Some(LifecycleScript::from_args(vec![
1061 "echo".to_string(),
1062 "post_start_command".to_string()
1063 ])),
1064 post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
1065 (
1066 "something".to_string(),
1067 vec!["echo".to_string(), "post_attach_command".to_string()]
1068 ),
1069 (
1070 "something1".to_string(),
1071 vec![
1072 "echo".to_string(),
1073 "something".to_string(),
1074 "else".to_string()
1075 ]
1076 )
1077 ]))),
1078 wait_for: Some(LifecycleCommand::PostStartCommand),
1079 user_env_probe: Some(UserEnvProbe::LoginShell),
1080 features: Some(HashMap::from([
1081 (
1082 "ghcr.io/devcontainers/features/aws-cli:1".to_string(),
1083 FeatureOptions::Options(HashMap::new())
1084 ),
1085 (
1086 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1087 FeatureOptions::Options(HashMap::new())
1088 )
1089 ])),
1090 override_feature_install_order: Some(vec![
1091 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1092 "ghcr.io/devcontainers/features/aws-cli:1".to_string()
1093 ]),
1094 host_requirements: Some(HostRequirements {
1095 cpus: Some(2),
1096 memory: Some("8gb".to_string()),
1097 storage: Some("32gb".to_string()),
1098 }),
1099 docker_compose_file: Some(vec!["docker-compose.yml".to_string()]),
1100 service: Some("myService".to_string()),
1101 run_services: Some(vec![
1102 "myService".to_string(),
1103 "mySupportingService".to_string(),
1104 ]),
1105 workspace_folder: Some("/workspaces/thing".to_string()),
1106 shutdown_action: Some(ShutdownAction::StopCompose),
1107 override_command: Some(true),
1108 ..Default::default()
1109 }
1110 );
1111
1112 assert_eq!(
1113 devcontainer.build_type(),
1114 DevContainerBuildType::DockerCompose
1115 );
1116 }
1117
1118 #[test]
1119 fn should_deserialize_dockerfile_devcontainer_json() {
1120 let given_dockerfile_container_json = r#"
1121 // These are some external comments. serde_lenient should handle them
1122 {
1123 // These are some internal comments
1124 "name": "myDevContainer",
1125 "remoteUser": "root",
1126 "forwardPorts": [
1127 "db:5432",
1128 3000
1129 ],
1130 "portsAttributes": {
1131 "3000": {
1132 "label": "This Port",
1133 "onAutoForward": "notify",
1134 "elevateIfNeeded": false,
1135 "requireLocalPort": true,
1136 "protocol": "https"
1137 },
1138 "db:5432": {
1139 "label": "This Port too",
1140 "onAutoForward": "silent",
1141 "elevateIfNeeded": true,
1142 "requireLocalPort": false,
1143 "protocol": "http"
1144 }
1145 },
1146 "otherPortsAttributes": {
1147 "label": "Other Ports",
1148 "onAutoForward": "openBrowser",
1149 "elevateIfNeeded": true,
1150 "requireLocalPort": true,
1151 "protocol": "https"
1152 },
1153 "updateRemoteUserUID": true,
1154 "remoteEnv": {
1155 "MYVAR1": "myvarvalue",
1156 "MYVAR2": "myvarothervalue"
1157 },
1158 "initializeCommand": ["echo", "initialize_command"],
1159 "onCreateCommand": "echo on_create_command",
1160 "updateContentCommand": {
1161 "first": "echo update_content_command",
1162 "second": ["echo", "update_content_command"]
1163 },
1164 "postCreateCommand": ["echo", "post_create_command"],
1165 "postStartCommand": "echo post_start_command",
1166 "postAttachCommand": {
1167 "something": "echo post_attach_command",
1168 "something1": "echo something else",
1169 },
1170 "waitFor": "postStartCommand",
1171 "userEnvProbe": "loginShell",
1172 "features": {
1173 "ghcr.io/devcontainers/features/aws-cli:1": {},
1174 "ghcr.io/devcontainers/features/anaconda:1": {}
1175 },
1176 "overrideFeatureInstallOrder": [
1177 "ghcr.io/devcontainers/features/anaconda:1",
1178 "ghcr.io/devcontainers/features/aws-cli:1"
1179 ],
1180 "hostRequirements": {
1181 "cpus": 2,
1182 "memory": "8gb",
1183 "storage": "32gb",
1184 // Note that we're not parsing this currently
1185 "gpu": true,
1186 },
1187 "appPort": 8081,
1188 "containerEnv": {
1189 "MYVAR3": "myvar3",
1190 "MYVAR4": "myvar4"
1191 },
1192 "containerUser": "myUser",
1193 "mounts": [
1194 {
1195 "source": "/localfolder/app",
1196 "target": "/workspaces/app",
1197 "type": "volume"
1198 },
1199 "source=dev-containers-cli-bashhistory,target=/home/node/commandhistory",
1200 ],
1201 "runArgs": [
1202 "-c",
1203 "some_command"
1204 ],
1205 "shutdownAction": "stopContainer",
1206 "overrideCommand": true,
1207 "workspaceFolder": "/workspaces",
1208 "workspaceMount": "source=/folder,target=/workspace,type=bind,consistency=cached",
1209 "build": {
1210 "dockerfile": "DockerFile",
1211 "context": "..",
1212 "args": {
1213 "MYARG": "MYVALUE"
1214 },
1215 "options": [
1216 "--some-option",
1217 "--mount"
1218 ],
1219 "target": "development",
1220 "cacheFrom": "some_image"
1221 }
1222 }
1223 "#;
1224
1225 let result = deserialize_devcontainer_json(given_dockerfile_container_json);
1226
1227 assert!(result.is_ok());
1228 let devcontainer = result.expect("ok");
1229 assert_eq!(
1230 devcontainer,
1231 DevContainer {
1232 name: Some(String::from("myDevContainer")),
1233 remote_user: Some(String::from("root")),
1234 forward_ports: Some(vec![
1235 ForwardPort::String("db:5432".to_string()),
1236 ForwardPort::Number(3000),
1237 ]),
1238 ports_attributes: Some(HashMap::from([
1239 (
1240 "3000".to_string(),
1241 PortAttributes {
1242 label: "This Port".to_string(),
1243 on_auto_forward: OnAutoForward::Notify,
1244 elevate_if_needed: false,
1245 require_local_port: true,
1246 protocol: PortAttributeProtocol::Https
1247 }
1248 ),
1249 (
1250 "db:5432".to_string(),
1251 PortAttributes {
1252 label: "This Port too".to_string(),
1253 on_auto_forward: OnAutoForward::Silent,
1254 elevate_if_needed: true,
1255 require_local_port: false,
1256 protocol: PortAttributeProtocol::Http
1257 }
1258 )
1259 ])),
1260 other_ports_attributes: Some(PortAttributes {
1261 label: "Other Ports".to_string(),
1262 on_auto_forward: OnAutoForward::OpenBrowser,
1263 elevate_if_needed: true,
1264 require_local_port: true,
1265 protocol: PortAttributeProtocol::Https
1266 }),
1267 update_remote_user_uid: Some(true),
1268 remote_env: Some(HashMap::from([
1269 ("MYVAR1".to_string(), "myvarvalue".to_string()),
1270 ("MYVAR2".to_string(), "myvarothervalue".to_string())
1271 ])),
1272 initialize_command: Some(LifecycleScript::from_args(vec![
1273 "echo".to_string(),
1274 "initialize_command".to_string()
1275 ])),
1276 on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
1277 update_content_command: Some(LifecycleScript::from_map(HashMap::from([
1278 (
1279 "first".to_string(),
1280 vec!["echo".to_string(), "update_content_command".to_string()]
1281 ),
1282 (
1283 "second".to_string(),
1284 vec!["echo".to_string(), "update_content_command".to_string()]
1285 )
1286 ]))),
1287 post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
1288 post_start_command: Some(LifecycleScript::from_args(vec![
1289 "echo".to_string(),
1290 "post_start_command".to_string()
1291 ])),
1292 post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
1293 (
1294 "something".to_string(),
1295 vec!["echo".to_string(), "post_attach_command".to_string()]
1296 ),
1297 (
1298 "something1".to_string(),
1299 vec![
1300 "echo".to_string(),
1301 "something".to_string(),
1302 "else".to_string()
1303 ]
1304 )
1305 ]))),
1306 wait_for: Some(LifecycleCommand::PostStartCommand),
1307 user_env_probe: Some(UserEnvProbe::LoginShell),
1308 features: Some(HashMap::from([
1309 (
1310 "ghcr.io/devcontainers/features/aws-cli:1".to_string(),
1311 FeatureOptions::Options(HashMap::new())
1312 ),
1313 (
1314 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1315 FeatureOptions::Options(HashMap::new())
1316 )
1317 ])),
1318 override_feature_install_order: Some(vec![
1319 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1320 "ghcr.io/devcontainers/features/aws-cli:1".to_string()
1321 ]),
1322 host_requirements: Some(HostRequirements {
1323 cpus: Some(2),
1324 memory: Some("8gb".to_string()),
1325 storage: Some("32gb".to_string()),
1326 }),
1327 app_port: vec!["8081:8081".to_string()],
1328 container_env: Some(HashMap::from([
1329 ("MYVAR3".to_string(), "myvar3".to_string()),
1330 ("MYVAR4".to_string(), "myvar4".to_string())
1331 ])),
1332 container_user: Some("myUser".to_string()),
1333 mounts: Some(vec![
1334 MountDefinition {
1335 source: Some("/localfolder/app".to_string()),
1336 target: "/workspaces/app".to_string(),
1337 mount_type: Some("volume".to_string()),
1338 },
1339 MountDefinition {
1340 source: Some("dev-containers-cli-bashhistory".to_string()),
1341 target: "/home/node/commandhistory".to_string(),
1342 mount_type: None,
1343 }
1344 ]),
1345 run_args: Some(vec!["-c".to_string(), "some_command".to_string()]),
1346 shutdown_action: Some(ShutdownAction::StopContainer),
1347 override_command: Some(true),
1348 workspace_folder: Some("/workspaces".to_string()),
1349 workspace_mount: Some(MountDefinition {
1350 source: Some("/folder".to_string()),
1351 target: "/workspace".to_string(),
1352 mount_type: Some("bind".to_string())
1353 }),
1354 build: Some(ContainerBuild {
1355 dockerfile: "DockerFile".to_string(),
1356 context: Some("..".to_string()),
1357 args: Some(HashMap::from([(
1358 "MYARG".to_string(),
1359 "MYVALUE".to_string()
1360 )])),
1361 options: Some(vec!["--some-option".to_string(), "--mount".to_string()]),
1362 target: Some("development".to_string()),
1363 cache_from: Some(vec!["some_image".to_string()]),
1364 }),
1365 ..Default::default()
1366 }
1367 );
1368
1369 assert_eq!(devcontainer.build_type(), DevContainerBuildType::Dockerfile);
1370 }
1371
1372 #[test]
1373 fn should_deserialize_app_port_array() {
1374 let given_json = r#"
1375 // These are some external comments. serde_lenient should handle them
1376 {
1377 // These are some internal comments
1378 "name": "myDevContainer",
1379 "remoteUser": "root",
1380 "appPort": [
1381 "8081:8083",
1382 "9001",
1383 ],
1384 "build": {
1385 "dockerfile": "DockerFile",
1386 }
1387 }
1388 "#;
1389
1390 let result = deserialize_devcontainer_json(given_json);
1391
1392 assert!(result.is_ok());
1393 let devcontainer = result.expect("ok");
1394
1395 assert_eq!(
1396 devcontainer.app_port,
1397 vec!["8081:8083".to_string(), "9001:9001".to_string()]
1398 )
1399 }
1400
1401 #[test]
1402 fn mount_definition_should_use_bind_type_for_unix_absolute_paths() {
1403 let mount = MountDefinition {
1404 source: Some("/home/user/project".to_string()),
1405 target: "/workspaces/project".to_string(),
1406 mount_type: None,
1407 };
1408
1409 let rendered = mount.to_string();
1410
1411 assert!(
1412 rendered.starts_with("type=bind,"),
1413 "Expected mount type 'bind' for Unix absolute path, but got: {rendered}"
1414 );
1415 }
1416
1417 #[test]
1418 fn mount_definition_should_use_bind_type_for_windows_unc_paths() {
1419 let mount = MountDefinition {
1420 source: Some("\\\\server\\share\\project".to_string()),
1421 target: "/workspaces/project".to_string(),
1422 mount_type: None,
1423 };
1424
1425 let rendered = mount.to_string();
1426
1427 assert!(
1428 rendered.starts_with("type=bind,"),
1429 "Expected mount type 'bind' for Windows UNC path, but got: {rendered}"
1430 );
1431 }
1432
1433 #[test]
1434 fn mount_definition_should_use_bind_type_for_windows_absolute_paths() {
1435 let mount = MountDefinition {
1436 source: Some("C:\\Users\\mrg\\cli".to_string()),
1437 target: "/workspaces/cli".to_string(),
1438 mount_type: None,
1439 };
1440
1441 let rendered = mount.to_string();
1442
1443 assert!(
1444 rendered.starts_with("type=bind,"),
1445 "Expected mount type 'bind' for Windows absolute path, but got: {rendered}"
1446 );
1447 }
1448
1449 #[test]
1450 fn mount_definition_should_omit_source_when_none() {
1451 let mount = MountDefinition {
1452 source: None,
1453 target: "/tmp".to_string(),
1454 mount_type: Some("tmpfs".to_string()),
1455 };
1456
1457 let rendered = mount.to_string();
1458
1459 assert_eq!(rendered, "type=tmpfs,target=/tmp,consistency=cached");
1460 }
1461}