microsandbox_core/config/microsandbox/
config.rs

1//! Microsandbox configuration types and helpers.
2
3use 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
24//--------------------------------------------------------------------------------------------------
25// Constants
26//--------------------------------------------------------------------------------------------------
27
28/// The start script name.
29pub const START_SCRIPT_NAME: &str = "start";
30
31/// The default network scope for a sandbox.
32pub const DEFAULT_NETWORK_SCOPE: NetworkScope = NetworkScope::Public;
33
34//--------------------------------------------------------------------------------------------------
35// Types
36//--------------------------------------------------------------------------------------------------
37
38/// The microsandbox configuration.
39#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Getters)]
40#[getset(get = "pub with_prefix")]
41pub struct Microsandbox {
42    /// The metadata about the configuration.
43    #[serde(skip_serializing_if = "Option::is_none", default)]
44    pub(crate) meta: Option<Meta>,
45
46    /// The modules to import.
47    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
48    pub(crate) modules: HashMap<String, Module>,
49
50    /// The builds to run.
51    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
52    pub(crate) builds: HashMap<String, Build>,
53
54    /// The sandboxes to run.
55    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
56    pub(crate) sandboxes: HashMap<String, Sandbox>,
57
58    /// The groups to run the sandboxes in.
59    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
60    pub(crate) groups: HashMap<String, Group>,
61}
62
63/// The metadata about the configuration.
64#[derive(Debug, Default, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Eq, Getters)]
65#[getset(get = "pub with_prefix")]
66pub struct Meta {
67    /// The authors of the configuration.
68    #[serde(skip_serializing_if = "Option::is_none", default)]
69    #[builder(default, setter(strip_option))]
70    pub(crate) authors: Option<Vec<String>>,
71
72    /// The description of the sandbox.
73    #[serde(skip_serializing_if = "Option::is_none", default)]
74    #[builder(default, setter(strip_option))]
75    pub(crate) description: Option<String>,
76
77    /// The homepage of the configuration.
78    #[serde(skip_serializing_if = "Option::is_none", default)]
79    #[builder(default, setter(strip_option))]
80    pub(crate) homepage: Option<String>,
81
82    /// The repository of the configuration.
83    #[serde(skip_serializing_if = "Option::is_none", default)]
84    #[builder(default, setter(strip_option))]
85    pub(crate) repository: Option<String>,
86
87    /// The path to the readme file.
88    #[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    /// The tags for the configuration.
98    #[serde(skip_serializing_if = "Option::is_none", default)]
99    #[builder(default, setter(strip_option))]
100    pub(crate) tags: Option<Vec<String>>,
101
102    /// The icon for the configuration.
103    #[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/// Component mapping for imports.
114#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Getters)]
115#[getset(get = "pub with_prefix")]
116pub struct ComponentMapping {
117    /// The alias for the component.
118    #[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/// Module import configuration.
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
125pub struct Module(pub HashMap<String, Option<ComponentMapping>>);
126
127/// A build to run.
128#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Getters)]
129#[getset(get = "pub with_prefix")]
130pub struct Build {
131    /// The image to use. This can be a path to a local rootfs or an OCI image reference.
132    pub(crate) image: ReferenceOrPath,
133
134    /// The amount of memory in MiB to use.
135    #[serde(skip_serializing_if = "Option::is_none", default)]
136    #[builder(default, setter(strip_option))]
137    pub(crate) memory: Option<u32>,
138
139    /// The number of vCPUs to use.
140    #[serde(skip_serializing_if = "Option::is_none", default)]
141    #[builder(default, setter(strip_option))]
142    pub(crate) cpus: Option<u8>,
143
144    /// The volumes to mount.
145    #[serde(skip_serializing_if = "Vec::is_empty", default)]
146    #[builder(default)]
147    pub(crate) volumes: Vec<PathPair>,
148
149    /// The ports to expose.
150    #[serde(skip_serializing_if = "Vec::is_empty", default)]
151    #[builder(default)]
152    pub(crate) ports: Vec<PortPair>,
153
154    /// The environment variables to use.
155    #[serde(skip_serializing_if = "Vec::is_empty", default)]
156    #[builder(default)]
157    pub(crate) envs: Vec<EnvPair>,
158
159    /// The builds to depend on.
160    #[serde(skip_serializing_if = "Vec::is_empty", default)]
161    #[builder(default)]
162    pub(crate) depends_on: Vec<String>,
163
164    /// The working directory to use.
165    #[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    /// The shell to use.
175    #[serde(skip_serializing_if = "Option::is_none", default)]
176    #[builder(default, setter(strip_option))]
177    pub(crate) shell: Option<String>,
178
179    /// The steps that will be run.
180    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
181    #[builder(default)]
182    pub(crate) steps: HashMap<String, String>,
183
184    /// The command to run. This is a list of command and arguments.
185    #[serde(skip_serializing_if = "Vec::is_empty", default)]
186    #[builder(default)]
187    pub(crate) command: Vec<String>,
188
189    /// The files to import.
190    #[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    /// The artifacts produced by the build.
200    #[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/// Network scope configuration for a sandbox.
211#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
212#[repr(u8)]
213pub enum NetworkScope {
214    /// Sandboxes cannot communicate with any other sandboxes
215    #[serde(rename = "none")]
216    None = 0,
217
218    /// Sandboxes can only communicate within their subnet
219    #[serde(rename = "group")]
220    Group = 1,
221
222    /// Sandboxes can communicate with any other non-private address
223    #[serde(rename = "public")]
224    #[default]
225    Public = 2,
226
227    /// Sandboxes can communicate with any address
228    #[serde(rename = "any")]
229    Any = 3,
230}
231
232/// Network configuration for a sandbox in a group.
233#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Eq, Getters)]
234#[getset(get = "pub with_prefix")]
235pub struct SandboxGroupNetwork {
236    /// The IP address for the sandbox in this group
237    #[serde(skip_serializing_if = "Option::is_none", default)]
238    #[builder(default, setter(strip_option))]
239    pub(crate) ip: Option<Ipv4Addr>,
240
241    /// The hostname for this sandbox in the group
242    #[serde(skip_serializing_if = "Option::is_none", default)]
243    #[builder(default, setter(strip_option))]
244    pub(crate) hostname: Option<String>,
245}
246
247/// Network configuration for a group.
248#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Eq, Getters)]
249#[getset(get = "pub with_prefix")]
250pub struct GroupNetwork {
251    /// The subnet CIDR for the group. Must be an IPv4 network.
252    #[serde(skip_serializing_if = "Option::is_none", default)]
253    #[builder(default, setter(strip_option))]
254    pub(crate) subnet: Option<Ipv4Net>,
255}
256
257/// The sandbox to run.
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Getters, Setters)]
259#[getset(get = "pub with_prefix", set = "pub with_prefix")]
260pub struct Sandbox {
261    /// The version of the sandbox.
262    #[serde(skip_serializing_if = "Option::is_none", default)]
263    pub(crate) version: Option<Version>,
264
265    /// The metadata about the sandbox.
266    #[serde(skip_serializing_if = "Option::is_none", default)]
267    pub(crate) meta: Option<Meta>,
268
269    /// The image to use. This can be a path to a local rootfs or an OCI image reference.
270    pub(crate) image: ReferenceOrPath,
271
272    /// The amount of memory in MiB to use.
273    #[serde(skip_serializing_if = "Option::is_none", default)]
274    pub(crate) memory: Option<u32>,
275
276    /// The number of vCPUs to use.
277    #[serde(skip_serializing_if = "Option::is_none", default)]
278    pub(crate) cpus: Option<u8>,
279
280    /// The volumes to mount.
281    #[serde(skip_serializing_if = "Vec::is_empty", default)]
282    pub(crate) volumes: Vec<PathPair>,
283
284    /// The ports to expose.
285    #[serde(skip_serializing_if = "Vec::is_empty", default)]
286    pub(crate) ports: Vec<PortPair>,
287
288    /// The environment variables to use.
289    #[serde(skip_serializing_if = "Vec::is_empty", default)]
290    pub(crate) envs: Vec<EnvPair>,
291
292    /// The groups to run in.
293    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
294    pub(crate) groups: HashMap<String, SandboxGroup>,
295
296    /// The sandboxes to depend on.
297    #[serde(skip_serializing_if = "Vec::is_empty", default)]
298    pub(crate) depends_on: Vec<String>,
299
300    /// The working directory to use.
301    #[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    /// The shell to use.
310    #[serde(skip_serializing_if = "Option::is_none", default)]
311    pub(crate) shell: Option<String>,
312
313    /// The scripts that can be run.
314    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
315    pub(crate) scripts: HashMap<String, String>,
316
317    /// The command to run. This is a list of command and arguments.
318    #[serde(skip_serializing_if = "Vec::is_empty", default)]
319    pub(crate) command: Vec<String>,
320
321    /// The files to import.
322    #[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    /// The artifacts produced by the sandbox.
331    #[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    /// The network scope for the sandbox.
340    #[serde(default)]
341    pub(crate) scope: NetworkScope,
342}
343
344/// Configuration for a sandbox's group membership.
345#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Getters)]
346#[getset(get = "pub with_prefix")]
347pub struct SandboxGroup {
348    /// The volumes to mount.
349    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
350    #[builder(default)]
351    pub(crate) volumes: HashMap<String, String>,
352
353    /// The network configuration for this sandbox in the group.
354    #[serde(skip_serializing_if = "Option::is_none", default)]
355    #[builder(default, setter(strip_option))]
356    pub(crate) network: Option<SandboxGroupNetwork>,
357}
358
359/// The group to run the sandboxes in.
360#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder, PartialEq, Eq, Getters)]
361#[getset(get = "pub with_prefix")]
362pub struct Group {
363    /// The version of the group.
364    #[serde(skip_serializing_if = "Option::is_none", default)]
365    #[builder(default, setter(strip_option))]
366    pub(crate) version: Option<Version>,
367
368    /// The metadata about the group.
369    #[serde(skip_serializing_if = "Option::is_none", default)]
370    #[builder(default, setter(strip_option))]
371    pub(crate) meta: Option<Meta>,
372
373    /// The network configuration for the group.
374    #[serde(skip_serializing_if = "Option::is_none", default)]
375    #[builder(default, setter(strip_option))]
376    pub(crate) network: Option<GroupNetwork>,
377
378    /// The volumes to mount.
379    #[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
389//--------------------------------------------------------------------------------------------------
390// Methods
391//--------------------------------------------------------------------------------------------------
392
393impl Microsandbox {
394    /// The maximum sandbox dependency chain length.
395    pub const MAX_DEPENDENCY_DEPTH: usize = 32;
396
397    /// Get a sandbox by name in this configuration
398    pub fn get_sandbox(&self, sandbox_name: &str) -> Option<&Sandbox> {
399        self.sandboxes.get(sandbox_name)
400    }
401
402    /// Get a group by name in this configuration
403    pub fn get_group(&self, group_name: &str) -> Option<&Group> {
404        self.groups.get(group_name)
405    }
406
407    /// Get a build by name in this configuration
408    pub fn get_build(&self, build_name: &str) -> Option<&Build> {
409        self.builds.get(build_name)
410    }
411
412    /// Validates the configuration.
413    pub fn validate(&self) -> MicrosandboxResult<()> {
414        // Validate all sandboxes
415        for sandbox in self.sandboxes.values() {
416            sandbox.validate()?;
417        }
418
419        Ok(())
420    }
421
422    /// Returns a builder for the Microsandbox configuration.
423    ///
424    /// See [`MicrosandboxBuilder`] for options.
425    pub fn builder() -> MicrosandboxBuilder {
426        MicrosandboxBuilder::default()
427    }
428}
429
430impl Sandbox {
431    /// Returns a builder for the sandbox.
432    ///
433    /// See [`SandboxBuilder`] for options.
434    pub fn builder() -> SandboxBuilder<()> {
435        SandboxBuilder::default()
436    }
437
438    /// Validates the configuration.
439    pub fn validate(&self) -> MicrosandboxResult<()> {
440        // Error if start and exec are both not defined
441        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
452//--------------------------------------------------------------------------------------------------
453// Trait Implementations
454//--------------------------------------------------------------------------------------------------
455
456impl 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
511//--------------------------------------------------------------------------------------------------
512// Functions: Serialization helpers
513//--------------------------------------------------------------------------------------------------
514
515fn 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//--------------------------------------------------------------------------------------------------
567// Tests
568//--------------------------------------------------------------------------------------------------
569
570#[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        // Test Default trait implementation
592        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        // Test empty sections
600        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        // Test default scope for sandbox is Group
643        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        // Test default scope in YAML
650        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        // Verify meta section
700        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        // Verify sandbox section
722        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        // Test modules
816        let modules = &config.modules;
817        assert!(modules.contains_key("./database.yaml"));
818        assert!(modules.contains_key("./redis.yaml"));
819
820        // Fix for the ComponentMapping.as_() error
821        let redis_module = &modules.get("./redis.yaml").unwrap().0;
822        let redis_comp = redis_module.get("redis").unwrap().as_ref().unwrap();
823        // Access as_ field directly as a field, not a method
824        assert_eq!(redis_comp.as_.as_ref().unwrap(), "cache");
825
826        // Test builds
827        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        // Test sandboxes
850        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        // Test groups
875        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        // Fix temporary value dropped issue by using direct reference
927        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        // Fix temporary value dropped issue for groups
948        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        // Test invalid scope
985        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        // Test invalid IP address
995        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        // Test invalid subnet
1008        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        // Test invalid version
1017        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        // Check version
1161        assert_eq!(group.version.as_ref().unwrap().to_string(), "2.1.0");
1162
1163        // Check metadata
1164        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        // Check network
1180        let network = group.network.as_ref().unwrap();
1181        assert_eq!(network.subnet.unwrap().to_string(), "10.1.0.0/16");
1182
1183        // Check volumes
1184        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        // Check that sandboxes are properly associated with groups
1233        let sandboxes = &config.sandboxes;
1234        let groups = &config.groups;
1235
1236        // Check web sandbox in frontend group
1237        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        // Check api sandbox in backend and frontend groups
1256        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        // Check group subnets
1293        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        // Check subnets of each group
1345        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}