1use std::{
4 collections::HashMap,
5 fmt::{self, Display},
6 net::Ipv4Addr,
7 str::FromStr,
8};
9
10use getset::{Getters, Setters};
11use ipnetwork::Ipv4Network as Ipv4Net;
12use semver::Version;
13use serde::{Deserialize, Serialize};
14use typed_builder::TypedBuilder;
15use typed_path::Utf8UnixPathBuf;
16
17use crate::{
18 config::{EnvPair, PathPair, PortPair, ReferenceOrPath},
19 MicrosandboxError, MicrosandboxResult,
20};
21
22use super::{MicrosandboxBuilder, SandboxBuilder};
23
24pub const START_SCRIPT_NAME: &str = "start";
30
31pub const DEFAULT_NETWORK_SCOPE: NetworkScope = NetworkScope::Public;
33
34#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Getters)]
40#[getset(get = "pub with_prefix")]
41pub struct Microsandbox {
42 #[serde(skip_serializing_if = "Option::is_none", default)]
44 pub(crate) meta: Option<Meta>,
45
46 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
48 pub(crate) modules: HashMap<String, Module>,
49
50 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
52 pub(crate) builds: HashMap<String, Build>,
53
54 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
56 pub(crate) sandboxes: HashMap<String, Sandbox>,
57
58 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
60 pub(crate) groups: HashMap<String, Group>,
61}
62
63#[derive(Debug, Default, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Eq, Getters)]
65#[getset(get = "pub with_prefix")]
66pub struct Meta {
67 #[serde(skip_serializing_if = "Option::is_none", default)]
69 #[builder(default, setter(strip_option))]
70 pub(crate) authors: Option<Vec<String>>,
71
72 #[serde(skip_serializing_if = "Option::is_none", default)]
74 #[builder(default, setter(strip_option))]
75 pub(crate) description: Option<String>,
76
77 #[serde(skip_serializing_if = "Option::is_none", default)]
79 #[builder(default, setter(strip_option))]
80 pub(crate) homepage: Option<String>,
81
82 #[serde(skip_serializing_if = "Option::is_none", default)]
84 #[builder(default, setter(strip_option))]
85 pub(crate) repository: Option<String>,
86
87 #[serde(
89 skip_serializing_if = "Option::is_none",
90 default,
91 serialize_with = "serialize_optional_path",
92 deserialize_with = "deserialize_optional_path"
93 )]
94 #[builder(default, setter(strip_option))]
95 pub(crate) readme: Option<Utf8UnixPathBuf>,
96
97 #[serde(skip_serializing_if = "Option::is_none", default)]
99 #[builder(default, setter(strip_option))]
100 pub(crate) tags: Option<Vec<String>>,
101
102 #[serde(
104 skip_serializing_if = "Option::is_none",
105 default,
106 serialize_with = "serialize_optional_path",
107 deserialize_with = "deserialize_optional_path"
108 )]
109 #[builder(default, setter(strip_option))]
110 pub(crate) icon: Option<Utf8UnixPathBuf>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Getters)]
115#[getset(get = "pub with_prefix")]
116pub struct ComponentMapping {
117 #[serde(skip_serializing_if = "Option::is_none", default, rename = "as")]
119 #[builder(default, setter(strip_option))]
120 pub(crate) as_: Option<String>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
125pub struct Module(pub HashMap<String, Option<ComponentMapping>>);
126
127#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Getters)]
129#[getset(get = "pub with_prefix")]
130pub struct Build {
131 pub(crate) image: ReferenceOrPath,
133
134 #[serde(skip_serializing_if = "Option::is_none", default)]
136 #[builder(default, setter(strip_option))]
137 pub(crate) memory: Option<u32>,
138
139 #[serde(skip_serializing_if = "Option::is_none", default)]
141 #[builder(default, setter(strip_option))]
142 pub(crate) cpus: Option<u8>,
143
144 #[serde(skip_serializing_if = "Vec::is_empty", default)]
146 #[builder(default)]
147 pub(crate) volumes: Vec<PathPair>,
148
149 #[serde(skip_serializing_if = "Vec::is_empty", default)]
151 #[builder(default)]
152 pub(crate) ports: Vec<PortPair>,
153
154 #[serde(skip_serializing_if = "Vec::is_empty", default)]
156 #[builder(default)]
157 pub(crate) envs: Vec<EnvPair>,
158
159 #[serde(skip_serializing_if = "Vec::is_empty", default)]
161 #[builder(default)]
162 pub(crate) depends_on: Vec<String>,
163
164 #[serde(
166 skip_serializing_if = "Option::is_none",
167 default,
168 serialize_with = "serialize_optional_path",
169 deserialize_with = "deserialize_optional_path"
170 )]
171 #[builder(default, setter(strip_option))]
172 pub(crate) workdir: Option<Utf8UnixPathBuf>,
173
174 #[serde(skip_serializing_if = "Option::is_none", default)]
176 #[builder(default, setter(strip_option))]
177 pub(crate) shell: Option<String>,
178
179 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
181 #[builder(default)]
182 pub(crate) steps: HashMap<String, String>,
183
184 #[serde(skip_serializing_if = "Vec::is_empty", default)]
186 #[builder(default)]
187 pub(crate) command: Vec<String>,
188
189 #[serde(
191 skip_serializing_if = "HashMap::is_empty",
192 default,
193 serialize_with = "serialize_path_map",
194 deserialize_with = "deserialize_path_map"
195 )]
196 #[builder(default)]
197 pub(crate) imports: HashMap<String, Utf8UnixPathBuf>,
198
199 #[serde(
201 skip_serializing_if = "HashMap::is_empty",
202 default,
203 serialize_with = "serialize_path_map",
204 deserialize_with = "deserialize_path_map"
205 )]
206 #[builder(default)]
207 pub(crate) exports: HashMap<String, Utf8UnixPathBuf>,
208}
209
210#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
212#[repr(u8)]
213pub enum NetworkScope {
214 #[serde(rename = "none")]
216 None = 0,
217
218 #[serde(rename = "group")]
220 Group = 1,
221
222 #[serde(rename = "public")]
224 #[default]
225 Public = 2,
226
227 #[serde(rename = "any")]
229 Any = 3,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Eq, Getters)]
234#[getset(get = "pub with_prefix")]
235pub struct SandboxGroupNetwork {
236 #[serde(skip_serializing_if = "Option::is_none", default)]
238 #[builder(default, setter(strip_option))]
239 pub(crate) ip: Option<Ipv4Addr>,
240
241 #[serde(skip_serializing_if = "Option::is_none", default)]
243 #[builder(default, setter(strip_option))]
244 pub(crate) hostname: Option<String>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Eq, Getters)]
249#[getset(get = "pub with_prefix")]
250pub struct GroupNetwork {
251 #[serde(skip_serializing_if = "Option::is_none", default)]
253 #[builder(default, setter(strip_option))]
254 pub(crate) subnet: Option<Ipv4Net>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Getters, Setters)]
259#[getset(get = "pub with_prefix", set = "pub with_prefix")]
260pub struct Sandbox {
261 #[serde(skip_serializing_if = "Option::is_none", default)]
263 pub(crate) version: Option<Version>,
264
265 #[serde(skip_serializing_if = "Option::is_none", default)]
267 pub(crate) meta: Option<Meta>,
268
269 pub(crate) image: ReferenceOrPath,
271
272 #[serde(skip_serializing_if = "Option::is_none", default)]
274 pub(crate) memory: Option<u32>,
275
276 #[serde(skip_serializing_if = "Option::is_none", default)]
278 pub(crate) cpus: Option<u8>,
279
280 #[serde(skip_serializing_if = "Vec::is_empty", default)]
282 pub(crate) volumes: Vec<PathPair>,
283
284 #[serde(skip_serializing_if = "Vec::is_empty", default)]
286 pub(crate) ports: Vec<PortPair>,
287
288 #[serde(skip_serializing_if = "Vec::is_empty", default)]
290 pub(crate) envs: Vec<EnvPair>,
291
292 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
294 pub(crate) groups: HashMap<String, SandboxGroup>,
295
296 #[serde(skip_serializing_if = "Vec::is_empty", default)]
298 pub(crate) depends_on: Vec<String>,
299
300 #[serde(
302 skip_serializing_if = "Option::is_none",
303 default,
304 serialize_with = "serialize_optional_path",
305 deserialize_with = "deserialize_optional_path"
306 )]
307 pub(crate) workdir: Option<Utf8UnixPathBuf>,
308
309 #[serde(skip_serializing_if = "Option::is_none", default)]
311 pub(crate) shell: Option<String>,
312
313 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
315 pub(crate) scripts: HashMap<String, String>,
316
317 #[serde(skip_serializing_if = "Vec::is_empty", default)]
319 pub(crate) command: Vec<String>,
320
321 #[serde(
323 skip_serializing_if = "HashMap::is_empty",
324 default,
325 serialize_with = "serialize_path_map",
326 deserialize_with = "deserialize_path_map"
327 )]
328 pub(crate) imports: HashMap<String, Utf8UnixPathBuf>,
329
330 #[serde(
332 skip_serializing_if = "HashMap::is_empty",
333 default,
334 serialize_with = "serialize_path_map",
335 deserialize_with = "deserialize_path_map"
336 )]
337 pub(crate) exports: HashMap<String, Utf8UnixPathBuf>,
338
339 #[serde(default)]
341 pub(crate) scope: NetworkScope,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Getters)]
346#[getset(get = "pub with_prefix")]
347pub struct SandboxGroup {
348 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
350 #[builder(default)]
351 pub(crate) volumes: HashMap<String, String>,
352
353 #[serde(skip_serializing_if = "Option::is_none", default)]
355 #[builder(default, setter(strip_option))]
356 pub(crate) network: Option<SandboxGroupNetwork>,
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Eq, Getters)]
361#[getset(get = "pub with_prefix")]
362pub struct Group {
363 #[serde(skip_serializing_if = "Option::is_none", default)]
365 #[builder(default, setter(strip_option))]
366 pub(crate) version: Option<Version>,
367
368 #[serde(skip_serializing_if = "Option::is_none", default)]
370 #[builder(default, setter(strip_option))]
371 pub(crate) meta: Option<Meta>,
372
373 #[serde(skip_serializing_if = "Option::is_none", default)]
375 #[builder(default, setter(strip_option))]
376 pub(crate) network: Option<GroupNetwork>,
377
378 #[serde(
380 skip_serializing_if = "HashMap::is_empty",
381 default,
382 serialize_with = "serialize_path_map",
383 deserialize_with = "deserialize_path_map"
384 )]
385 #[builder(default)]
386 pub(crate) volumes: HashMap<String, Utf8UnixPathBuf>,
387}
388
389impl Microsandbox {
394 pub const MAX_DEPENDENCY_DEPTH: usize = 32;
396
397 pub fn get_sandbox(&self, sandbox_name: &str) -> Option<&Sandbox> {
399 self.sandboxes.get(sandbox_name)
400 }
401
402 pub fn get_group(&self, group_name: &str) -> Option<&Group> {
404 self.groups.get(group_name)
405 }
406
407 pub fn get_build(&self, build_name: &str) -> Option<&Build> {
409 self.builds.get(build_name)
410 }
411
412 pub fn validate(&self) -> MicrosandboxResult<()> {
414 for sandbox in self.sandboxes.values() {
416 sandbox.validate()?;
417 }
418
419 Ok(())
420 }
421
422 pub fn builder() -> MicrosandboxBuilder {
426 MicrosandboxBuilder::default()
427 }
428}
429
430impl Sandbox {
431 pub fn builder() -> SandboxBuilder<()> {
435 SandboxBuilder::default()
436 }
437
438 pub fn validate(&self) -> MicrosandboxResult<()> {
440 if self.scripts.get(START_SCRIPT_NAME).is_none()
442 && self.command.is_empty()
443 && self.shell.is_none()
444 {
445 return Err(MicrosandboxError::MissingStartOrExecOrShell);
446 }
447
448 Ok(())
449 }
450}
451
452impl TryFrom<&str> for NetworkScope {
457 type Error = MicrosandboxError;
458
459 fn try_from(s: &str) -> Result<Self, Self::Error> {
460 match s.to_lowercase().as_str() {
461 "none" => Ok(NetworkScope::None),
462 "group" => Ok(NetworkScope::Group),
463 "public" => Ok(NetworkScope::Public),
464 "any" => Ok(NetworkScope::Any),
465 _ => Err(MicrosandboxError::InvalidNetworkScope(s.to_string())),
466 }
467 }
468}
469
470impl Display for NetworkScope {
471 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
472 match self {
473 NetworkScope::None => write!(f, "none"),
474 NetworkScope::Group => write!(f, "group"),
475 NetworkScope::Public => write!(f, "public"),
476 NetworkScope::Any => write!(f, "any"),
477 }
478 }
479}
480
481impl FromStr for NetworkScope {
482 type Err = anyhow::Error;
483
484 fn from_str(s: &str) -> Result<Self, Self::Err> {
485 Ok(NetworkScope::try_from(s)?)
486 }
487}
488
489impl TryFrom<String> for NetworkScope {
490 type Error = MicrosandboxError;
491
492 fn try_from(s: String) -> Result<Self, Self::Error> {
493 Ok(NetworkScope::try_from(s.as_str())?)
494 }
495}
496
497impl TryFrom<u8> for NetworkScope {
498 type Error = MicrosandboxError;
499
500 fn try_from(u: u8) -> Result<Self, Self::Error> {
501 match u {
502 0 => Ok(NetworkScope::None),
503 1 => Ok(NetworkScope::Group),
504 2 => Ok(NetworkScope::Public),
505 3 => Ok(NetworkScope::Any),
506 _ => Err(MicrosandboxError::InvalidNetworkScope(u.to_string())),
507 }
508 }
509}
510
511fn serialize_optional_path<S>(
516 path: &Option<Utf8UnixPathBuf>,
517 serializer: S,
518) -> Result<S::Ok, S::Error>
519where
520 S: serde::Serializer,
521{
522 match path {
523 Some(p) => serializer.serialize_str(p.as_str()),
524 None => serializer.serialize_none(),
525 }
526}
527
528fn deserialize_optional_path<'de, D>(deserializer: D) -> Result<Option<Utf8UnixPathBuf>, D::Error>
529where
530 D: serde::Deserializer<'de>,
531{
532 Option::<String>::deserialize(deserializer)?
533 .map(|s| Ok(Utf8UnixPathBuf::from(s)))
534 .transpose()
535}
536
537fn serialize_path_map<S>(
538 map: &HashMap<String, Utf8UnixPathBuf>,
539 serializer: S,
540) -> Result<S::Ok, S::Error>
541where
542 S: serde::Serializer,
543{
544 use serde::ser::SerializeMap;
545 let mut map_ser = serializer.serialize_map(Some(map.len()))?;
546 for (k, v) in map {
547 map_ser.serialize_entry(k, v.as_str())?;
548 }
549 map_ser.end()
550}
551
552fn deserialize_path_map<'de, D>(
553 deserializer: D,
554) -> Result<HashMap<String, Utf8UnixPathBuf>, D::Error>
555where
556 D: serde::Deserializer<'de>,
557{
558 HashMap::<String, String>::deserialize(deserializer).map(|string_map| {
559 string_map
560 .into_iter()
561 .map(|(k, v)| (k, Utf8UnixPathBuf::from(v)))
562 .collect()
563 })
564}
565
566#[cfg(test)]
571mod tests {
572 use super::*;
573 use std::net::Ipv4Addr;
574
575 #[test]
576 fn test_microsandbox_config_empty_config() {
577 let yaml = r#"
578 # Empty config with no fields
579 "#;
580
581 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
582 assert!(config.meta.is_none());
583 assert!(config.modules.is_empty());
584 assert!(config.builds.is_empty());
585 assert!(config.sandboxes.is_empty());
586 assert!(config.groups.is_empty());
587 }
588
589 #[test]
590 fn test_microsandbox_config_default_config() {
591 let config = Microsandbox::default();
593 assert!(config.meta.is_none());
594 assert!(config.modules.is_empty());
595 assert!(config.builds.is_empty());
596 assert!(config.sandboxes.is_empty());
597 assert!(config.groups.is_empty());
598
599 let yaml = r#"
601 meta: {}
602 modules: {}
603 builds: {}
604 sandboxes: {}
605 groups: {}
606 "#;
607
608 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
609 assert!(config.meta.unwrap() == Meta::default());
610 assert!(config.modules.is_empty());
611 assert!(config.builds.is_empty());
612 assert!(config.sandboxes.is_empty());
613 assert!(config.groups.is_empty());
614 }
615
616 #[test]
617 fn test_microsandbox_config_minimal_sandbox_config() {
618 let yaml = r#"
619 sandboxes:
620 test:
621 image: "alpine:latest"
622 "#;
623
624 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
625 let sandboxes = &config.sandboxes;
626 let sandbox = sandboxes.get("test").unwrap();
627
628 assert!(sandbox.version.is_none());
629 assert!(sandbox.memory.is_none());
630 assert!(sandbox.cpus.is_none());
631 assert!(sandbox.volumes.is_empty());
632 assert!(sandbox.ports.is_empty());
633 assert!(sandbox.envs.is_empty());
634 assert!(sandbox.workdir.is_none());
635 assert!(sandbox.shell.is_none());
636 assert!(sandbox.scripts.is_empty());
637 assert_eq!(sandbox.scope, NetworkScope::Group);
638 }
639
640 #[test]
641 fn test_microsandbox_config_default_scope() {
642 let sandbox = Sandbox::builder()
644 .image(ReferenceOrPath::Reference("alpine:latest".parse().unwrap()))
645 .shell("/bin/sh")
646 .build();
647 assert_eq!(sandbox.scope, NetworkScope::Group);
648
649 let yaml = r#"
651 sandboxes:
652 test:
653 image: "alpine:latest"
654 shell: "/bin/sh"
655 "#;
656
657 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
658 let sandboxes = &config.sandboxes;
659 let sandbox = sandboxes.get("test").unwrap();
660
661 assert_eq!(sandbox.scope, NetworkScope::Group);
662 }
663
664 #[test]
665 fn test_microsandbox_config_basic_microsandbox_config() {
666 let yaml = r#"
667 meta:
668 authors:
669 - "John Doe <john@example.com>"
670 description: "Test configuration"
671 homepage: "https://example.com"
672 repository: "https://github.com/example/test"
673 readme: "./README.md"
674 tags:
675 - "test"
676 - "example"
677 icon: "./icon.png"
678
679 sandboxes:
680 test_sandbox:
681 version: "1.0.0"
682 image: "alpine:latest"
683 memory: 1024
684 cpus: 2
685 volumes:
686 - "./src:/app/src"
687 ports:
688 - "8080:80"
689 envs:
690 - "DEBUG=true"
691 workdir: "/app"
692 shell: "/bin/sh"
693 scripts:
694 start: "echo 'Hello, World!'"
695 "#;
696
697 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
698
699 let meta = config.meta.as_ref().unwrap();
701 assert_eq!(
702 meta.authors.as_ref().unwrap()[0],
703 "John Doe <john@example.com>"
704 );
705 assert_eq!(meta.description.as_ref().unwrap(), "Test configuration");
706 assert_eq!(meta.homepage.as_ref().unwrap(), "https://example.com");
707 assert_eq!(
708 meta.repository.as_ref().unwrap(),
709 "https://github.com/example/test"
710 );
711 assert_eq!(
712 meta.readme.as_ref().unwrap(),
713 &Utf8UnixPathBuf::from("./README.md")
714 );
715 assert_eq!(meta.tags.as_ref().unwrap(), &vec!["test", "example"]);
716 assert_eq!(
717 meta.icon.as_ref().unwrap(),
718 &Utf8UnixPathBuf::from("./icon.png")
719 );
720
721 let sandboxes = &config.sandboxes;
723 let sandbox = sandboxes.get("test_sandbox").unwrap();
724 assert_eq!(sandbox.version.as_ref().unwrap().to_string(), "1.0.0");
725 assert_eq!(sandbox.memory.unwrap(), 1024);
726 assert_eq!(sandbox.cpus.unwrap(), 2);
727 assert_eq!(sandbox.volumes[0].to_string(), "./src:/app/src");
728 assert_eq!(sandbox.ports[0].to_string(), "8080:80");
729 assert_eq!(sandbox.envs[0].to_string(), "DEBUG=true");
730 assert_eq!(
731 sandbox.workdir.as_ref().unwrap(),
732 &Utf8UnixPathBuf::from("/app")
733 );
734 assert_eq!(sandbox.shell, Some("/bin/sh".to_string()));
735 assert_eq!(
736 sandbox.scripts.get("start").unwrap(),
737 "echo 'Hello, World!'"
738 );
739 }
740
741 #[test]
742 fn test_microsandbox_config_full_microsandbox_config() {
743 let yaml = r#"
744 meta:
745 description: "Full test configuration"
746
747 modules:
748 "./database.yaml":
749 database: {}
750 "./redis.yaml":
751 redis:
752 as: "cache"
753
754 builds:
755 base_build:
756 image: "python:3.11-slim"
757 memory: 2048
758 cpus: 2
759 volumes:
760 - "./requirements.txt:/build/requirements.txt"
761 envs:
762 - "PYTHON_VERSION=3.11"
763 workdir: "/build"
764 shell: "/bin/bash"
765 steps:
766 build: "pip install -r requirements.txt"
767 imports:
768 requirements: "./requirements.txt"
769 exports:
770 packages: "/build/dist/packages"
771 groups:
772 build_group:
773 volumes:
774 logs: "/var/log"
775
776 sandboxes:
777 api:
778 version: "1.0.0"
779 image: "python:3.11-slim"
780 memory: 1024
781 cpus: 1
782 volumes:
783 - "./api:/app/src"
784 ports:
785 - "8000:8000"
786 envs:
787 - "DEBUG=false"
788 depends_on:
789 - "database"
790 - "cache"
791 workdir: "/app"
792 shell: "/bin/bash"
793 scripts:
794 start: "python -m uvicorn src.main:app"
795 scope: "public"
796 groups:
797 backend_group:
798 network:
799 ip: "10.0.1.10"
800 hostname: "api.internal"
801
802 groups:
803 backend_group:
804 version: "1.0.0"
805 meta:
806 description: "Backend services group"
807 network:
808 subnet: "10.0.1.0/24"
809 volumes:
810 logs: "/var/log"
811 "#;
812
813 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
814
815 let modules = &config.modules;
817 assert!(modules.contains_key("./database.yaml"));
818 assert!(modules.contains_key("./redis.yaml"));
819
820 let redis_module = &modules.get("./redis.yaml").unwrap().0;
822 let redis_comp = redis_module.get("redis").unwrap().as_ref().unwrap();
823 assert_eq!(redis_comp.as_.as_ref().unwrap(), "cache");
825
826 let builds = &config.builds;
828 let base_build = builds.get("base_build").unwrap();
829 assert_eq!(base_build.memory.unwrap(), 2048);
830 assert_eq!(base_build.cpus.unwrap(), 2);
831 assert_eq!(
832 base_build.workdir.as_ref().unwrap(),
833 &Utf8UnixPathBuf::from("/build")
834 );
835 assert_eq!(base_build.shell, Some("/bin/bash".to_string()));
836 assert_eq!(
837 base_build.steps.get("build").unwrap(),
838 "pip install -r requirements.txt"
839 );
840 assert_eq!(
841 base_build.imports.get("requirements").unwrap(),
842 &Utf8UnixPathBuf::from("./requirements.txt")
843 );
844 assert_eq!(
845 base_build.exports.get("packages").unwrap(),
846 &Utf8UnixPathBuf::from("/build/dist/packages")
847 );
848
849 let sandboxes = &config.sandboxes;
851 let api = sandboxes.get("api").unwrap();
852 assert_eq!(api.version.as_ref().unwrap().to_string(), "1.0.0");
853 assert_eq!(api.memory.unwrap(), 1024);
854 assert_eq!(api.cpus.unwrap(), 1);
855 assert_eq!(api.depends_on, vec!["database", "cache"]);
856 assert_eq!(api.scope, NetworkScope::Public);
857
858 let api_group = api.groups.get("backend_group").unwrap();
859 assert_eq!(
860 api_group.network.as_ref().unwrap().ip.unwrap(),
861 Ipv4Addr::new(10, 0, 1, 10)
862 );
863 assert_eq!(
864 api_group
865 .network
866 .as_ref()
867 .unwrap()
868 .hostname
869 .as_ref()
870 .unwrap(),
871 "api.internal"
872 );
873
874 let groups = &config.groups;
876 let backend_group = groups.get("backend_group").unwrap();
877 assert_eq!(backend_group.version.as_ref().unwrap().to_string(), "1.0.0");
878 assert_eq!(
879 backend_group
880 .meta
881 .as_ref()
882 .unwrap()
883 .description
884 .as_ref()
885 .unwrap(),
886 "Backend services group"
887 );
888 assert_eq!(
889 backend_group
890 .network
891 .as_ref()
892 .unwrap()
893 .subnet
894 .unwrap()
895 .to_string(),
896 "10.0.1.0/24"
897 );
898 assert_eq!(
899 backend_group.volumes.get("logs").unwrap(),
900 &Utf8UnixPathBuf::from("/var/log")
901 );
902 }
903
904 #[test]
905 fn test_microsandbox_config_network_configuration() {
906 let yaml = r#"
907 sandboxes:
908 test_sandbox:
909 image: "alpine:latest"
910 shell: "/bin/sh"
911 scope: "group"
912 groups:
913 test_group:
914 network:
915 ip: "10.0.1.10"
916 hostname: "test.internal"
917
918 groups:
919 test_group:
920 network:
921 subnet: "10.0.1.0/24"
922 "#;
923
924 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
925
926 let sandboxes = &config.sandboxes;
928 let sandbox = sandboxes.get("test_sandbox").unwrap();
929 assert_eq!(sandbox.scope, NetworkScope::Group);
930
931 let sandbox_group = sandbox.groups.get("test_group").unwrap();
932 assert_eq!(
933 sandbox_group.network.as_ref().unwrap().ip.unwrap(),
934 Ipv4Addr::new(10, 0, 1, 10)
935 );
936 assert_eq!(
937 sandbox_group
938 .network
939 .as_ref()
940 .unwrap()
941 .hostname
942 .as_ref()
943 .unwrap(),
944 "test.internal"
945 );
946
947 let groups = &config.groups;
949 let group = groups.get("test_group").unwrap();
950 assert_eq!(
951 group.network.as_ref().unwrap().subnet.unwrap().to_string(),
952 "10.0.1.0/24"
953 );
954 }
955
956 #[test]
957 fn test_microsandbox_config_build_dependencies() {
958 let yaml = r#"
959 builds:
960 base:
961 image: "python:3.11-slim"
962 depends_on: ["deps"]
963 deps:
964 image: "python:3.11-slim"
965 scripts:
966 install: "pip install -r requirements.txt"
967 "#;
968
969 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
970 let builds = &config.builds;
971
972 let base = builds.get("base").unwrap();
973 assert_eq!(base.depends_on, vec!["deps"]);
974
975 let deps = builds.get("deps").unwrap();
976 assert_eq!(
977 deps.steps.get("install").unwrap(),
978 "pip install -r requirements.txt"
979 );
980 }
981
982 #[test]
983 fn test_microsandbox_config_invalid_configurations() {
984 let yaml = r#"
986 sandboxes:
987 test:
988 image: "alpine:latest"
989 shell: "/bin/sh"
990 scope: "invalid"
991 "#;
992 assert!(serde_yaml::from_str::<Microsandbox>(yaml).is_err());
993
994 let yaml = r#"
996 sandboxes:
997 test:
998 image: "alpine:latest"
999 shell: "/bin/sh"
1000 groups:
1001 test_group:
1002 network:
1003 ip: "invalid"
1004 "#;
1005 assert!(serde_yaml::from_str::<Microsandbox>(yaml).is_err());
1006
1007 let yaml = r#"
1009 groups:
1010 test:
1011 network:
1012 subnet: "invalid"
1013 "#;
1014 assert!(serde_yaml::from_str::<Microsandbox>(yaml).is_err());
1015
1016 let yaml = r#"
1018 sandboxes:
1019 test:
1020 image: "alpine:latest"
1021 shell: "/bin/sh"
1022 version: "invalid"
1023 "#;
1024 assert!(serde_yaml::from_str::<Microsandbox>(yaml).is_err());
1025 }
1026
1027 #[test]
1028 fn test_microsandbox_config_group_basic() {
1029 let yaml = r#"
1030 groups:
1031 simple_group:
1032 version: "1.0.0"
1033 "#;
1034
1035 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
1036 let groups = &config.groups;
1037
1038 assert!(groups.contains_key("simple_group"));
1039 let group = groups.get("simple_group").unwrap();
1040 assert_eq!(group.version.as_ref().unwrap().to_string(), "1.0.0");
1041 assert!(group.meta.is_none());
1042 assert!(group.network.is_none());
1043 assert!(group.volumes.is_empty());
1044 }
1045
1046 #[test]
1047 fn test_microsandbox_config_group_metadata() {
1048 let yaml = r#"
1049 groups:
1050 metadata_group:
1051 meta:
1052 description: "A group with metadata"
1053 authors:
1054 - "Test Author <test@example.com>"
1055 tags:
1056 - "test"
1057 - "metadata"
1058 "#;
1059
1060 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
1061 let groups = &config.groups;
1062
1063 assert!(groups.contains_key("metadata_group"));
1064 let group = groups.get("metadata_group").unwrap();
1065 assert!(group.version.is_none());
1066
1067 let meta = group.meta.as_ref().unwrap();
1068 assert_eq!(meta.description.as_ref().unwrap(), "A group with metadata");
1069 assert_eq!(
1070 meta.authors.as_ref().unwrap()[0],
1071 "Test Author <test@example.com>"
1072 );
1073 assert_eq!(meta.tags.as_ref().unwrap(), &vec!["test", "metadata"]);
1074 }
1075
1076 #[test]
1077 fn test_microsandbox_config_group_network() {
1078 let yaml = r#"
1079 groups:
1080 network_group:
1081 network:
1082 subnet: "10.0.2.0/24"
1083 "#;
1084
1085 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
1086 let groups = &config.groups;
1087
1088 assert!(groups.contains_key("network_group"));
1089 let group = groups.get("network_group").unwrap();
1090 assert!(group.version.is_none());
1091 assert!(group.meta.is_none());
1092
1093 let network = group.network.as_ref().unwrap();
1094 assert_eq!(network.subnet.unwrap().to_string(), "10.0.2.0/24");
1095 }
1096
1097 #[test]
1098 fn test_microsandbox_config_group_volumes() {
1099 let yaml = r#"
1100 groups:
1101 volume_group:
1102 volumes:
1103 data: "/data"
1104 logs: "/var/log"
1105 static: "/var/www/static"
1106 "#;
1107
1108 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
1109 let groups = &config.groups;
1110
1111 assert!(groups.contains_key("volume_group"));
1112 let group = groups.get("volume_group").unwrap();
1113 assert!(group.version.is_none());
1114 assert!(group.meta.is_none());
1115 assert!(group.network.is_none());
1116
1117 let volumes = &group.volumes;
1118 assert_eq!(volumes.len(), 3);
1119 assert_eq!(
1120 volumes.get("data").unwrap(),
1121 &Utf8UnixPathBuf::from("/data")
1122 );
1123 assert_eq!(
1124 volumes.get("logs").unwrap(),
1125 &Utf8UnixPathBuf::from("/var/log")
1126 );
1127 assert_eq!(
1128 volumes.get("static").unwrap(),
1129 &Utf8UnixPathBuf::from("/var/www/static")
1130 );
1131 }
1132
1133 #[test]
1134 fn test_microsandbox_config_group_complete() {
1135 let yaml = r#"
1136 groups:
1137 complete_group:
1138 version: "2.1.0"
1139 meta:
1140 description: "A complete group with all properties"
1141 authors:
1142 - "Test Author <test@example.com>"
1143 tags:
1144 - "test"
1145 - "complete"
1146 readme: "./README.md"
1147 network:
1148 subnet: "10.1.0.0/16"
1149 volumes:
1150 cache: "/var/cache"
1151 db: "/var/lib/database"
1152 "#;
1153
1154 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
1155 let groups = &config.groups;
1156
1157 assert!(groups.contains_key("complete_group"));
1158 let group = groups.get("complete_group").unwrap();
1159
1160 assert_eq!(group.version.as_ref().unwrap().to_string(), "2.1.0");
1162
1163 let meta = group.meta.as_ref().unwrap();
1165 assert_eq!(
1166 meta.description.as_ref().unwrap(),
1167 "A complete group with all properties"
1168 );
1169 assert_eq!(
1170 meta.authors.as_ref().unwrap()[0],
1171 "Test Author <test@example.com>"
1172 );
1173 assert_eq!(meta.tags.as_ref().unwrap(), &vec!["test", "complete"]);
1174 assert_eq!(
1175 meta.readme.as_ref().unwrap(),
1176 &Utf8UnixPathBuf::from("./README.md")
1177 );
1178
1179 let network = group.network.as_ref().unwrap();
1181 assert_eq!(network.subnet.unwrap().to_string(), "10.1.0.0/16");
1182
1183 let volumes = &group.volumes;
1185 assert_eq!(volumes.len(), 2);
1186 assert_eq!(
1187 volumes.get("cache").unwrap(),
1188 &Utf8UnixPathBuf::from("/var/cache")
1189 );
1190 assert_eq!(
1191 volumes.get("db").unwrap(),
1192 &Utf8UnixPathBuf::from("/var/lib/database")
1193 );
1194 }
1195
1196 #[test]
1197 fn test_microsandbox_config_group_sandbox_association() {
1198 let yaml = r#"
1199 sandboxes:
1200 web:
1201 image: "nginx:alpine"
1202 shell: "/bin/sh"
1203 groups:
1204 frontend_group:
1205 network:
1206 ip: "10.2.0.10"
1207 hostname: "web.internal"
1208 api:
1209 image: "python:3.9-slim"
1210 shell: "/bin/bash"
1211 groups:
1212 backend_group:
1213 network:
1214 ip: "10.3.0.20"
1215 hostname: "api.internal"
1216 frontend_group:
1217 network:
1218 ip: "10.2.0.20"
1219 hostname: "api-frontend.internal"
1220
1221 groups:
1222 frontend_group:
1223 network:
1224 subnet: "10.2.0.0/24"
1225 backend_group:
1226 network:
1227 subnet: "10.3.0.0/24"
1228 "#;
1229
1230 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
1231
1232 let sandboxes = &config.sandboxes;
1234 let groups = &config.groups;
1235
1236 let web = sandboxes.get("web").unwrap();
1238 assert!(web.groups.contains_key("frontend_group"));
1239 let web_frontend = web.groups.get("frontend_group").unwrap();
1240 assert_eq!(
1241 web_frontend.network.as_ref().unwrap().ip.unwrap(),
1242 Ipv4Addr::new(10, 2, 0, 10)
1243 );
1244 assert_eq!(
1245 web_frontend
1246 .network
1247 .as_ref()
1248 .unwrap()
1249 .hostname
1250 .as_ref()
1251 .unwrap(),
1252 "web.internal"
1253 );
1254
1255 let api = sandboxes.get("api").unwrap();
1257 assert!(api.groups.contains_key("backend_group"));
1258 assert!(api.groups.contains_key("frontend_group"));
1259
1260 let api_backend = api.groups.get("backend_group").unwrap();
1261 assert_eq!(
1262 api_backend.network.as_ref().unwrap().ip.unwrap(),
1263 Ipv4Addr::new(10, 3, 0, 20)
1264 );
1265 assert_eq!(
1266 api_backend
1267 .network
1268 .as_ref()
1269 .unwrap()
1270 .hostname
1271 .as_ref()
1272 .unwrap(),
1273 "api.internal"
1274 );
1275
1276 let api_frontend = api.groups.get("frontend_group").unwrap();
1277 assert_eq!(
1278 api_frontend.network.as_ref().unwrap().ip.unwrap(),
1279 Ipv4Addr::new(10, 2, 0, 20)
1280 );
1281 assert_eq!(
1282 api_frontend
1283 .network
1284 .as_ref()
1285 .unwrap()
1286 .hostname
1287 .as_ref()
1288 .unwrap(),
1289 "api-frontend.internal"
1290 );
1291
1292 let frontend_group = groups.get("frontend_group").unwrap();
1294 assert_eq!(
1295 frontend_group
1296 .network
1297 .as_ref()
1298 .unwrap()
1299 .subnet
1300 .unwrap()
1301 .to_string(),
1302 "10.2.0.0/24"
1303 );
1304
1305 let backend_group = groups.get("backend_group").unwrap();
1306 assert_eq!(
1307 backend_group
1308 .network
1309 .as_ref()
1310 .unwrap()
1311 .subnet
1312 .unwrap()
1313 .to_string(),
1314 "10.3.0.0/24"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_microsandbox_config_group_multiple() {
1320 let yaml = r#"
1321 groups:
1322 group_a:
1323 version: "1.0.0"
1324 network:
1325 subnet: "10.10.0.0/24"
1326 group_b:
1327 version: "1.0.0"
1328 network:
1329 subnet: "10.20.0.0/24"
1330 group_c:
1331 version: "1.0.0"
1332 network:
1333 subnet: "10.30.0.0/24"
1334 "#;
1335
1336 let config: Microsandbox = serde_yaml::from_str(yaml).unwrap();
1337 let groups = &config.groups;
1338
1339 assert_eq!(groups.len(), 3);
1340 assert!(groups.contains_key("group_a"));
1341 assert!(groups.contains_key("group_b"));
1342 assert!(groups.contains_key("group_c"));
1343
1344 assert_eq!(
1346 groups
1347 .get("group_a")
1348 .unwrap()
1349 .network
1350 .as_ref()
1351 .unwrap()
1352 .subnet
1353 .unwrap()
1354 .to_string(),
1355 "10.10.0.0/24"
1356 );
1357 assert_eq!(
1358 groups
1359 .get("group_b")
1360 .unwrap()
1361 .network
1362 .as_ref()
1363 .unwrap()
1364 .subnet
1365 .unwrap()
1366 .to_string(),
1367 "10.20.0.0/24"
1368 );
1369 assert_eq!(
1370 groups
1371 .get("group_c")
1372 .unwrap()
1373 .network
1374 .as_ref()
1375 .unwrap()
1376 .subnet
1377 .unwrap()
1378 .to_string(),
1379 "10.30.0.0/24"
1380 );
1381 }
1382}