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