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