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_string_or_int")]
221 pub(crate) app_port: Option<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_string_or_int<'de, D>(deserializer: D) -> Result<Option<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 match StringOrInt::deserialize(deserializer)? {
534 StringOrInt::String(s) => Ok(Some(s)),
535 StringOrInt::Int(b) => Ok(Some(b.to_string())),
536 }
537}
538
539fn deserialize_string_or_array<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
540where
541 D: serde::Deserializer<'de>,
542{
543 use serde::Deserialize;
544
545 #[derive(Deserialize)]
546 #[serde(untagged)]
547 enum StringOrArray {
548 String(String),
549 Array(Vec<String>),
550 }
551
552 match StringOrArray::deserialize(deserializer)? {
553 StringOrArray::String(s) => Ok(Some(vec![s])),
554 StringOrArray::Array(b) => Ok(Some(b)),
555 }
556}
557
558#[cfg(test)]
559mod test {
560 use std::collections::HashMap;
561
562 use crate::{
563 devcontainer_api::DevContainerError,
564 devcontainer_json::{
565 ContainerBuild, DevContainer, DevContainerBuildType, FeatureOptions, ForwardPort,
566 HostRequirements, LifecycleCommand, LifecycleScript, MountDefinition, OnAutoForward,
567 PortAttributeProtocol, PortAttributes, ShutdownAction, UserEnvProbe, ZedCustomization,
568 ZedCustomizationsWrapper, deserialize_devcontainer_json,
569 },
570 };
571
572 #[test]
573 fn should_deserialize_customizations_with_unknown_keys() {
574 let json_with_other_customizations = r#"
575 {
576 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
577 "customizations": {
578 "vscode": {
579 "extensions": [
580 "dbaeumer.vscode-eslint",
581 "GitHub.vscode-pull-request-github",
582 ],
583 },
584 "zed": {
585 "extensions": ["vue", "ruby"],
586 },
587 "codespaces": {
588 "repositories": {
589 "devcontainers/features": {
590 "permissions": {
591 "contents": "write",
592 "workflows": "write",
593 },
594 },
595 },
596 },
597 },
598 }
599 "#;
600
601 let result = deserialize_devcontainer_json(json_with_other_customizations);
602
603 assert!(
604 result.is_ok(),
605 "Should ignore unknown customization keys, but got: {:?}",
606 result.err()
607 );
608 let devcontainer = result.expect("ok");
609 assert_eq!(
610 devcontainer.customizations,
611 Some(ZedCustomizationsWrapper {
612 zed: ZedCustomization {
613 extensions: vec!["vue".to_string(), "ruby".to_string()]
614 }
615 })
616 );
617 }
618
619 #[test]
620 fn should_deserialize_customizations_without_zed_key() {
621 let json_without_zed = r#"
622 {
623 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
624 "customizations": {
625 "vscode": {
626 "extensions": ["dbaeumer.vscode-eslint"]
627 }
628 }
629 }
630 "#;
631
632 let result = deserialize_devcontainer_json(json_without_zed);
633
634 assert!(
635 result.is_ok(),
636 "Should handle missing zed key in customizations, but got: {:?}",
637 result.err()
638 );
639 let devcontainer = result.expect("ok");
640 assert_eq!(
641 devcontainer.customizations,
642 Some(ZedCustomizationsWrapper {
643 zed: ZedCustomization { extensions: vec![] }
644 })
645 );
646 }
647
648 #[test]
649 fn should_deserialize_simple_devcontainer_json() {
650 let given_bad_json = "{ \"image\": 123 }";
651
652 let result = deserialize_devcontainer_json(given_bad_json);
653
654 assert!(result.is_err());
655 assert_eq!(
656 result.expect_err("err"),
657 DevContainerError::DevContainerParseFailed
658 );
659
660 let given_image_container_json = r#"
661 // These are some external comments. serde_lenient should handle them
662 {
663 // These are some internal comments
664 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
665 "name": "myDevContainer",
666 "remoteUser": "root",
667 "forwardPorts": [
668 "db:5432",
669 3000
670 ],
671 "portsAttributes": {
672 "3000": {
673 "label": "This Port",
674 "onAutoForward": "notify",
675 "elevateIfNeeded": false,
676 "requireLocalPort": true,
677 "protocol": "https"
678 },
679 "db:5432": {
680 "label": "This Port too",
681 "onAutoForward": "silent",
682 "elevateIfNeeded": true,
683 "requireLocalPort": false,
684 "protocol": "http"
685 }
686 },
687 "otherPortsAttributes": {
688 "label": "Other Ports",
689 "onAutoForward": "openBrowser",
690 "elevateIfNeeded": true,
691 "requireLocalPort": true,
692 "protocol": "https"
693 },
694 "updateRemoteUserUID": true,
695 "remoteEnv": {
696 "MYVAR1": "myvarvalue",
697 "MYVAR2": "myvarothervalue"
698 },
699 "initializeCommand": ["echo", "initialize_command"],
700 "onCreateCommand": "echo on_create_command",
701 "updateContentCommand": {
702 "first": "echo update_content_command",
703 "second": ["echo", "update_content_command"]
704 },
705 "postCreateCommand": ["echo", "post_create_command"],
706 "postStartCommand": "echo post_start_command",
707 "postAttachCommand": {
708 "something": "echo post_attach_command",
709 "something1": "echo something else",
710 },
711 "waitFor": "postStartCommand",
712 "userEnvProbe": "loginShell",
713 "features": {
714 "ghcr.io/devcontainers/features/aws-cli:1": {},
715 "ghcr.io/devcontainers/features/anaconda:1": {}
716 },
717 "overrideFeatureInstallOrder": [
718 "ghcr.io/devcontainers/features/anaconda:1",
719 "ghcr.io/devcontainers/features/aws-cli:1"
720 ],
721 "hostRequirements": {
722 "cpus": 2,
723 "memory": "8gb",
724 "storage": "32gb",
725 // Note that we're not parsing this currently
726 "gpu": true,
727 },
728 "appPort": 8081,
729 "containerEnv": {
730 "MYVAR3": "myvar3",
731 "MYVAR4": "myvar4"
732 },
733 "containerUser": "myUser",
734 "mounts": [
735 {
736 "source": "/localfolder/app",
737 "target": "/workspaces/app",
738 "type": "volume"
739 }
740 ],
741 "runArgs": [
742 "-c",
743 "some_command"
744 ],
745 "shutdownAction": "stopContainer",
746 "overrideCommand": true,
747 "workspaceFolder": "/workspaces",
748 "workspaceMount": "source=/app,target=/workspaces/app,type=bind,consistency=cached",
749 "customizations": {
750 "vscode": {
751 // Just confirm that this can be included and ignored
752 },
753 "zed": {
754 "extensions": [
755 "html"
756 ]
757 }
758 }
759 }
760 "#;
761
762 let result = deserialize_devcontainer_json(given_image_container_json);
763
764 assert!(result.is_ok());
765 let devcontainer = result.expect("ok");
766 assert_eq!(
767 devcontainer,
768 DevContainer {
769 image: Some(String::from("mcr.microsoft.com/devcontainers/base:ubuntu")),
770 name: Some(String::from("myDevContainer")),
771 remote_user: Some(String::from("root")),
772 forward_ports: Some(vec![
773 ForwardPort::String("db:5432".to_string()),
774 ForwardPort::Number(3000),
775 ]),
776 ports_attributes: Some(HashMap::from([
777 (
778 "3000".to_string(),
779 PortAttributes {
780 label: "This Port".to_string(),
781 on_auto_forward: OnAutoForward::Notify,
782 elevate_if_needed: false,
783 require_local_port: true,
784 protocol: PortAttributeProtocol::Https
785 }
786 ),
787 (
788 "db:5432".to_string(),
789 PortAttributes {
790 label: "This Port too".to_string(),
791 on_auto_forward: OnAutoForward::Silent,
792 elevate_if_needed: true,
793 require_local_port: false,
794 protocol: PortAttributeProtocol::Http
795 }
796 )
797 ])),
798 other_ports_attributes: Some(PortAttributes {
799 label: "Other Ports".to_string(),
800 on_auto_forward: OnAutoForward::OpenBrowser,
801 elevate_if_needed: true,
802 require_local_port: true,
803 protocol: PortAttributeProtocol::Https
804 }),
805 update_remote_user_uid: Some(true),
806 remote_env: Some(HashMap::from([
807 ("MYVAR1".to_string(), "myvarvalue".to_string()),
808 ("MYVAR2".to_string(), "myvarothervalue".to_string())
809 ])),
810 initialize_command: Some(LifecycleScript::from_args(vec![
811 "echo".to_string(),
812 "initialize_command".to_string()
813 ])),
814 on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
815 update_content_command: Some(LifecycleScript::from_map(HashMap::from([
816 (
817 "first".to_string(),
818 vec!["echo".to_string(), "update_content_command".to_string()]
819 ),
820 (
821 "second".to_string(),
822 vec!["echo".to_string(), "update_content_command".to_string()]
823 )
824 ]))),
825 post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
826 post_start_command: Some(LifecycleScript::from_args(vec![
827 "echo".to_string(),
828 "post_start_command".to_string()
829 ])),
830 post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
831 (
832 "something".to_string(),
833 vec!["echo".to_string(), "post_attach_command".to_string()]
834 ),
835 (
836 "something1".to_string(),
837 vec![
838 "echo".to_string(),
839 "something".to_string(),
840 "else".to_string()
841 ]
842 )
843 ]))),
844 wait_for: Some(LifecycleCommand::PostStartCommand),
845 user_env_probe: Some(UserEnvProbe::LoginShell),
846 features: Some(HashMap::from([
847 (
848 "ghcr.io/devcontainers/features/aws-cli:1".to_string(),
849 FeatureOptions::Options(HashMap::new())
850 ),
851 (
852 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
853 FeatureOptions::Options(HashMap::new())
854 )
855 ])),
856 override_feature_install_order: Some(vec![
857 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
858 "ghcr.io/devcontainers/features/aws-cli:1".to_string()
859 ]),
860 host_requirements: Some(HostRequirements {
861 cpus: Some(2),
862 memory: Some("8gb".to_string()),
863 storage: Some("32gb".to_string()),
864 }),
865 app_port: Some("8081".to_string()),
866 container_env: Some(HashMap::from([
867 ("MYVAR3".to_string(), "myvar3".to_string()),
868 ("MYVAR4".to_string(), "myvar4".to_string())
869 ])),
870 container_user: Some("myUser".to_string()),
871 mounts: Some(vec![MountDefinition {
872 source: Some("/localfolder/app".to_string()),
873 target: "/workspaces/app".to_string(),
874 mount_type: Some("volume".to_string()),
875 }]),
876 run_args: Some(vec!["-c".to_string(), "some_command".to_string()]),
877 shutdown_action: Some(ShutdownAction::StopContainer),
878 override_command: Some(true),
879 workspace_folder: Some("/workspaces".to_string()),
880 workspace_mount: Some(MountDefinition {
881 source: Some("/app".to_string()),
882 target: "/workspaces/app".to_string(),
883 mount_type: Some("bind".to_string())
884 }),
885 customizations: Some(ZedCustomizationsWrapper {
886 zed: ZedCustomization {
887 extensions: vec!["html".to_string()]
888 }
889 }),
890 ..Default::default()
891 }
892 );
893
894 assert_eq!(devcontainer.build_type(), DevContainerBuildType::Image);
895 }
896
897 #[test]
898 fn should_deserialize_docker_compose_devcontainer_json() {
899 let given_docker_compose_json = r#"
900 // These are some external comments. serde_lenient should handle them
901 {
902 // These are some internal comments
903 "name": "myDevContainer",
904 "remoteUser": "root",
905 "forwardPorts": [
906 "db:5432",
907 3000
908 ],
909 "portsAttributes": {
910 "3000": {
911 "label": "This Port",
912 "onAutoForward": "notify",
913 "elevateIfNeeded": false,
914 "requireLocalPort": true,
915 "protocol": "https"
916 },
917 "db:5432": {
918 "label": "This Port too",
919 "onAutoForward": "silent",
920 "elevateIfNeeded": true,
921 "requireLocalPort": false,
922 "protocol": "http"
923 }
924 },
925 "otherPortsAttributes": {
926 "label": "Other Ports",
927 "onAutoForward": "openBrowser",
928 "elevateIfNeeded": true,
929 "requireLocalPort": true,
930 "protocol": "https"
931 },
932 "updateRemoteUserUID": true,
933 "remoteEnv": {
934 "MYVAR1": "myvarvalue",
935 "MYVAR2": "myvarothervalue"
936 },
937 "initializeCommand": ["echo", "initialize_command"],
938 "onCreateCommand": "echo on_create_command",
939 "updateContentCommand": {
940 "first": "echo update_content_command",
941 "second": ["echo", "update_content_command"]
942 },
943 "postCreateCommand": ["echo", "post_create_command"],
944 "postStartCommand": "echo post_start_command",
945 "postAttachCommand": {
946 "something": "echo post_attach_command",
947 "something1": "echo something else",
948 },
949 "waitFor": "postStartCommand",
950 "userEnvProbe": "loginShell",
951 "features": {
952 "ghcr.io/devcontainers/features/aws-cli:1": {},
953 "ghcr.io/devcontainers/features/anaconda:1": {}
954 },
955 "overrideFeatureInstallOrder": [
956 "ghcr.io/devcontainers/features/anaconda:1",
957 "ghcr.io/devcontainers/features/aws-cli:1"
958 ],
959 "hostRequirements": {
960 "cpus": 2,
961 "memory": "8gb",
962 "storage": "32gb",
963 // Note that we're not parsing this currently
964 "gpu": true,
965 },
966 "dockerComposeFile": "docker-compose.yml",
967 "service": "myService",
968 "runServices": [
969 "myService",
970 "mySupportingService"
971 ],
972 "workspaceFolder": "/workspaces/thing",
973 "shutdownAction": "stopCompose",
974 "overrideCommand": true
975 }
976 "#;
977 let result = deserialize_devcontainer_json(given_docker_compose_json);
978
979 assert!(result.is_ok());
980 let devcontainer = result.expect("ok");
981 assert_eq!(
982 devcontainer,
983 DevContainer {
984 name: Some(String::from("myDevContainer")),
985 remote_user: Some(String::from("root")),
986 forward_ports: Some(vec![
987 ForwardPort::String("db:5432".to_string()),
988 ForwardPort::Number(3000),
989 ]),
990 ports_attributes: Some(HashMap::from([
991 (
992 "3000".to_string(),
993 PortAttributes {
994 label: "This Port".to_string(),
995 on_auto_forward: OnAutoForward::Notify,
996 elevate_if_needed: false,
997 require_local_port: true,
998 protocol: PortAttributeProtocol::Https
999 }
1000 ),
1001 (
1002 "db:5432".to_string(),
1003 PortAttributes {
1004 label: "This Port too".to_string(),
1005 on_auto_forward: OnAutoForward::Silent,
1006 elevate_if_needed: true,
1007 require_local_port: false,
1008 protocol: PortAttributeProtocol::Http
1009 }
1010 )
1011 ])),
1012 other_ports_attributes: Some(PortAttributes {
1013 label: "Other Ports".to_string(),
1014 on_auto_forward: OnAutoForward::OpenBrowser,
1015 elevate_if_needed: true,
1016 require_local_port: true,
1017 protocol: PortAttributeProtocol::Https
1018 }),
1019 update_remote_user_uid: Some(true),
1020 remote_env: Some(HashMap::from([
1021 ("MYVAR1".to_string(), "myvarvalue".to_string()),
1022 ("MYVAR2".to_string(), "myvarothervalue".to_string())
1023 ])),
1024 initialize_command: Some(LifecycleScript::from_args(vec![
1025 "echo".to_string(),
1026 "initialize_command".to_string()
1027 ])),
1028 on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
1029 update_content_command: Some(LifecycleScript::from_map(HashMap::from([
1030 (
1031 "first".to_string(),
1032 vec!["echo".to_string(), "update_content_command".to_string()]
1033 ),
1034 (
1035 "second".to_string(),
1036 vec!["echo".to_string(), "update_content_command".to_string()]
1037 )
1038 ]))),
1039 post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
1040 post_start_command: Some(LifecycleScript::from_args(vec![
1041 "echo".to_string(),
1042 "post_start_command".to_string()
1043 ])),
1044 post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
1045 (
1046 "something".to_string(),
1047 vec!["echo".to_string(), "post_attach_command".to_string()]
1048 ),
1049 (
1050 "something1".to_string(),
1051 vec![
1052 "echo".to_string(),
1053 "something".to_string(),
1054 "else".to_string()
1055 ]
1056 )
1057 ]))),
1058 wait_for: Some(LifecycleCommand::PostStartCommand),
1059 user_env_probe: Some(UserEnvProbe::LoginShell),
1060 features: Some(HashMap::from([
1061 (
1062 "ghcr.io/devcontainers/features/aws-cli:1".to_string(),
1063 FeatureOptions::Options(HashMap::new())
1064 ),
1065 (
1066 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1067 FeatureOptions::Options(HashMap::new())
1068 )
1069 ])),
1070 override_feature_install_order: Some(vec![
1071 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1072 "ghcr.io/devcontainers/features/aws-cli:1".to_string()
1073 ]),
1074 host_requirements: Some(HostRequirements {
1075 cpus: Some(2),
1076 memory: Some("8gb".to_string()),
1077 storage: Some("32gb".to_string()),
1078 }),
1079 docker_compose_file: Some(vec!["docker-compose.yml".to_string()]),
1080 service: Some("myService".to_string()),
1081 run_services: Some(vec![
1082 "myService".to_string(),
1083 "mySupportingService".to_string(),
1084 ]),
1085 workspace_folder: Some("/workspaces/thing".to_string()),
1086 shutdown_action: Some(ShutdownAction::StopCompose),
1087 override_command: Some(true),
1088 ..Default::default()
1089 }
1090 );
1091
1092 assert_eq!(
1093 devcontainer.build_type(),
1094 DevContainerBuildType::DockerCompose
1095 );
1096 }
1097
1098 #[test]
1099 fn should_deserialize_dockerfile_devcontainer_json() {
1100 let given_dockerfile_container_json = r#"
1101 // These are some external comments. serde_lenient should handle them
1102 {
1103 // These are some internal comments
1104 "name": "myDevContainer",
1105 "remoteUser": "root",
1106 "forwardPorts": [
1107 "db:5432",
1108 3000
1109 ],
1110 "portsAttributes": {
1111 "3000": {
1112 "label": "This Port",
1113 "onAutoForward": "notify",
1114 "elevateIfNeeded": false,
1115 "requireLocalPort": true,
1116 "protocol": "https"
1117 },
1118 "db:5432": {
1119 "label": "This Port too",
1120 "onAutoForward": "silent",
1121 "elevateIfNeeded": true,
1122 "requireLocalPort": false,
1123 "protocol": "http"
1124 }
1125 },
1126 "otherPortsAttributes": {
1127 "label": "Other Ports",
1128 "onAutoForward": "openBrowser",
1129 "elevateIfNeeded": true,
1130 "requireLocalPort": true,
1131 "protocol": "https"
1132 },
1133 "updateRemoteUserUID": true,
1134 "remoteEnv": {
1135 "MYVAR1": "myvarvalue",
1136 "MYVAR2": "myvarothervalue"
1137 },
1138 "initializeCommand": ["echo", "initialize_command"],
1139 "onCreateCommand": "echo on_create_command",
1140 "updateContentCommand": {
1141 "first": "echo update_content_command",
1142 "second": ["echo", "update_content_command"]
1143 },
1144 "postCreateCommand": ["echo", "post_create_command"],
1145 "postStartCommand": "echo post_start_command",
1146 "postAttachCommand": {
1147 "something": "echo post_attach_command",
1148 "something1": "echo something else",
1149 },
1150 "waitFor": "postStartCommand",
1151 "userEnvProbe": "loginShell",
1152 "features": {
1153 "ghcr.io/devcontainers/features/aws-cli:1": {},
1154 "ghcr.io/devcontainers/features/anaconda:1": {}
1155 },
1156 "overrideFeatureInstallOrder": [
1157 "ghcr.io/devcontainers/features/anaconda:1",
1158 "ghcr.io/devcontainers/features/aws-cli:1"
1159 ],
1160 "hostRequirements": {
1161 "cpus": 2,
1162 "memory": "8gb",
1163 "storage": "32gb",
1164 // Note that we're not parsing this currently
1165 "gpu": true,
1166 },
1167 "appPort": 8081,
1168 "containerEnv": {
1169 "MYVAR3": "myvar3",
1170 "MYVAR4": "myvar4"
1171 },
1172 "containerUser": "myUser",
1173 "mounts": [
1174 {
1175 "source": "/localfolder/app",
1176 "target": "/workspaces/app",
1177 "type": "volume"
1178 },
1179 "source=dev-containers-cli-bashhistory,target=/home/node/commandhistory",
1180 ],
1181 "runArgs": [
1182 "-c",
1183 "some_command"
1184 ],
1185 "shutdownAction": "stopContainer",
1186 "overrideCommand": true,
1187 "workspaceFolder": "/workspaces",
1188 "workspaceMount": "source=/folder,target=/workspace,type=bind,consistency=cached",
1189 "build": {
1190 "dockerfile": "DockerFile",
1191 "context": "..",
1192 "args": {
1193 "MYARG": "MYVALUE"
1194 },
1195 "options": [
1196 "--some-option",
1197 "--mount"
1198 ],
1199 "target": "development",
1200 "cacheFrom": "some_image"
1201 }
1202 }
1203 "#;
1204
1205 let result = deserialize_devcontainer_json(given_dockerfile_container_json);
1206
1207 assert!(result.is_ok());
1208 let devcontainer = result.expect("ok");
1209 assert_eq!(
1210 devcontainer,
1211 DevContainer {
1212 name: Some(String::from("myDevContainer")),
1213 remote_user: Some(String::from("root")),
1214 forward_ports: Some(vec![
1215 ForwardPort::String("db:5432".to_string()),
1216 ForwardPort::Number(3000),
1217 ]),
1218 ports_attributes: Some(HashMap::from([
1219 (
1220 "3000".to_string(),
1221 PortAttributes {
1222 label: "This Port".to_string(),
1223 on_auto_forward: OnAutoForward::Notify,
1224 elevate_if_needed: false,
1225 require_local_port: true,
1226 protocol: PortAttributeProtocol::Https
1227 }
1228 ),
1229 (
1230 "db:5432".to_string(),
1231 PortAttributes {
1232 label: "This Port too".to_string(),
1233 on_auto_forward: OnAutoForward::Silent,
1234 elevate_if_needed: true,
1235 require_local_port: false,
1236 protocol: PortAttributeProtocol::Http
1237 }
1238 )
1239 ])),
1240 other_ports_attributes: Some(PortAttributes {
1241 label: "Other Ports".to_string(),
1242 on_auto_forward: OnAutoForward::OpenBrowser,
1243 elevate_if_needed: true,
1244 require_local_port: true,
1245 protocol: PortAttributeProtocol::Https
1246 }),
1247 update_remote_user_uid: Some(true),
1248 remote_env: Some(HashMap::from([
1249 ("MYVAR1".to_string(), "myvarvalue".to_string()),
1250 ("MYVAR2".to_string(), "myvarothervalue".to_string())
1251 ])),
1252 initialize_command: Some(LifecycleScript::from_args(vec![
1253 "echo".to_string(),
1254 "initialize_command".to_string()
1255 ])),
1256 on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
1257 update_content_command: Some(LifecycleScript::from_map(HashMap::from([
1258 (
1259 "first".to_string(),
1260 vec!["echo".to_string(), "update_content_command".to_string()]
1261 ),
1262 (
1263 "second".to_string(),
1264 vec!["echo".to_string(), "update_content_command".to_string()]
1265 )
1266 ]))),
1267 post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
1268 post_start_command: Some(LifecycleScript::from_args(vec![
1269 "echo".to_string(),
1270 "post_start_command".to_string()
1271 ])),
1272 post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
1273 (
1274 "something".to_string(),
1275 vec!["echo".to_string(), "post_attach_command".to_string()]
1276 ),
1277 (
1278 "something1".to_string(),
1279 vec![
1280 "echo".to_string(),
1281 "something".to_string(),
1282 "else".to_string()
1283 ]
1284 )
1285 ]))),
1286 wait_for: Some(LifecycleCommand::PostStartCommand),
1287 user_env_probe: Some(UserEnvProbe::LoginShell),
1288 features: Some(HashMap::from([
1289 (
1290 "ghcr.io/devcontainers/features/aws-cli:1".to_string(),
1291 FeatureOptions::Options(HashMap::new())
1292 ),
1293 (
1294 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1295 FeatureOptions::Options(HashMap::new())
1296 )
1297 ])),
1298 override_feature_install_order: Some(vec![
1299 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1300 "ghcr.io/devcontainers/features/aws-cli:1".to_string()
1301 ]),
1302 host_requirements: Some(HostRequirements {
1303 cpus: Some(2),
1304 memory: Some("8gb".to_string()),
1305 storage: Some("32gb".to_string()),
1306 }),
1307 app_port: Some("8081".to_string()),
1308 container_env: Some(HashMap::from([
1309 ("MYVAR3".to_string(), "myvar3".to_string()),
1310 ("MYVAR4".to_string(), "myvar4".to_string())
1311 ])),
1312 container_user: Some("myUser".to_string()),
1313 mounts: Some(vec![
1314 MountDefinition {
1315 source: Some("/localfolder/app".to_string()),
1316 target: "/workspaces/app".to_string(),
1317 mount_type: Some("volume".to_string()),
1318 },
1319 MountDefinition {
1320 source: Some("dev-containers-cli-bashhistory".to_string()),
1321 target: "/home/node/commandhistory".to_string(),
1322 mount_type: None,
1323 }
1324 ]),
1325 run_args: Some(vec!["-c".to_string(), "some_command".to_string()]),
1326 shutdown_action: Some(ShutdownAction::StopContainer),
1327 override_command: Some(true),
1328 workspace_folder: Some("/workspaces".to_string()),
1329 workspace_mount: Some(MountDefinition {
1330 source: Some("/folder".to_string()),
1331 target: "/workspace".to_string(),
1332 mount_type: Some("bind".to_string())
1333 }),
1334 build: Some(ContainerBuild {
1335 dockerfile: "DockerFile".to_string(),
1336 context: Some("..".to_string()),
1337 args: Some(HashMap::from([(
1338 "MYARG".to_string(),
1339 "MYVALUE".to_string()
1340 )])),
1341 options: Some(vec!["--some-option".to_string(), "--mount".to_string()]),
1342 target: Some("development".to_string()),
1343 cache_from: Some(vec!["some_image".to_string()]),
1344 }),
1345 ..Default::default()
1346 }
1347 );
1348
1349 assert_eq!(devcontainer.build_type(), DevContainerBuildType::Dockerfile);
1350 }
1351
1352 #[test]
1353 fn mount_definition_should_use_bind_type_for_unix_absolute_paths() {
1354 let mount = MountDefinition {
1355 source: Some("/home/user/project".to_string()),
1356 target: "/workspaces/project".to_string(),
1357 mount_type: None,
1358 };
1359
1360 let rendered = mount.to_string();
1361
1362 assert!(
1363 rendered.starts_with("type=bind,"),
1364 "Expected mount type 'bind' for Unix absolute path, but got: {rendered}"
1365 );
1366 }
1367
1368 #[test]
1369 fn mount_definition_should_use_bind_type_for_windows_unc_paths() {
1370 let mount = MountDefinition {
1371 source: Some("\\\\server\\share\\project".to_string()),
1372 target: "/workspaces/project".to_string(),
1373 mount_type: None,
1374 };
1375
1376 let rendered = mount.to_string();
1377
1378 assert!(
1379 rendered.starts_with("type=bind,"),
1380 "Expected mount type 'bind' for Windows UNC path, but got: {rendered}"
1381 );
1382 }
1383
1384 #[test]
1385 fn mount_definition_should_use_bind_type_for_windows_absolute_paths() {
1386 let mount = MountDefinition {
1387 source: Some("C:\\Users\\mrg\\cli".to_string()),
1388 target: "/workspaces/cli".to_string(),
1389 mount_type: None,
1390 };
1391
1392 let rendered = mount.to_string();
1393
1394 assert!(
1395 rendered.starts_with("type=bind,"),
1396 "Expected mount type 'bind' for Windows absolute path, but got: {rendered}"
1397 );
1398 }
1399
1400 #[test]
1401 fn mount_definition_should_omit_source_when_none() {
1402 let mount = MountDefinition {
1403 source: None,
1404 target: "/tmp".to_string(),
1405 mount_type: Some("tmpfs".to_string()),
1406 };
1407
1408 let rendered = mount.to_string();
1409
1410 assert_eq!(rendered, "type=tmpfs,target=/tmp,consistency=cached");
1411 }
1412}