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