Skip to main content

a3s_box_core/
compose.rs

1//! Compose file types for multi-container orchestration.
2//!
3//! Defines A3S ACL and Docker Compose-compatible YAML schemas for declaring
4//! multi-service workloads. Each service maps to a single MicroVM.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9mod acl;
10mod diagnostic;
11mod interpolation;
12mod normalization;
13mod normalized;
14mod schema;
15
16pub use acl::ComposeAclError;
17pub use diagnostic::{ComposeDiagnostic, ComposeDiagnosticCode, ComposeNormalizationError};
18pub use interpolation::{interpolate_compose_yaml, ComposeInterpolationError};
19pub use normalization::{normalize_compose, normalize_compose_config, ComposeSourceFormat};
20pub use normalized::{
21    NormalizedComposeConfig, NormalizedDependsOn, NormalizedHealthcheckConfig,
22    NormalizedNetworkDeclaration, NormalizedServiceConfig, NormalizedServiceNetwork,
23    NormalizedVolumeDeclaration,
24};
25
26/// Top-level compose file configuration.
27///
28/// Compatible with a subset of docker-compose v3 syntax:
29/// ```yaml
30/// version: "3"
31/// services:
32///   web:
33///     image: nginx:latest
34///     ports: ["8080:80"]
35///     depends_on: [db]
36///   db:
37///     image: postgres:16
38///     environment:
39///       POSTGRES_PASSWORD: secret
40///     volumes: ["pgdata:/var/lib/postgresql/data"]
41/// volumes:
42///   pgdata:
43/// networks:
44///   default:
45/// ```
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct ComposeConfig {
49    /// Compose file version (informational, not enforced).
50    #[serde(default)]
51    pub version: Option<String>,
52
53    /// Service definitions keyed by name.
54    pub services: HashMap<String, ServiceConfig>,
55
56    /// Named volume declarations (value is currently unused, reserved for driver options).
57    #[serde(default)]
58    pub volumes: HashMap<String, Option<VolumeDeclaration>>,
59
60    /// Named network declarations.
61    #[serde(default)]
62    pub networks: HashMap<String, Option<NetworkDeclaration>>,
63}
64
65/// A single service in a compose file.
66#[derive(Debug, Clone, Serialize, Deserialize, Default)]
67#[serde(deny_unknown_fields)]
68pub struct ServiceConfig {
69    /// OCI image reference (e.g., "nginx:latest").
70    #[serde(default)]
71    pub image: Option<String>,
72
73    /// Override the container entrypoint.
74    #[serde(default)]
75    pub entrypoint: Option<StringOrList>,
76
77    /// Override the container command.
78    #[serde(default)]
79    pub command: Option<StringOrList>,
80
81    /// Environment variables.
82    #[serde(default)]
83    pub environment: EnvVars,
84
85    /// Environment files to load before `environment` overrides.
86    #[serde(default)]
87    pub env_file: StringOrList,
88
89    /// Transient environment bindings keyed by guest variable name, with
90    /// values naming process-environment variables owned by the caller.
91    /// Secret bytes are never part of this configuration model.
92    #[serde(default)]
93    pub secret_environment: HashMap<String, String>,
94
95    /// Port mappings ("host:container").
96    #[serde(default)]
97    pub ports: Vec<String>,
98
99    /// Volume mounts ("name:/path" or "/host:/container").
100    #[serde(default)]
101    pub volumes: Vec<String>,
102
103    /// Services this service depends on (started first).
104    #[serde(default)]
105    pub depends_on: DependsOn,
106
107    /// Networks to connect to.
108    #[serde(default)]
109    pub networks: ServiceNetworks,
110
111    /// Number of CPUs.
112    #[serde(default)]
113    pub cpus: Option<u32>,
114
115    /// Memory limit (e.g., "512m", "1g").
116    #[serde(default)]
117    pub mem_limit: Option<String>,
118
119    /// Restart policy: "no", "always", "on-failure", "unless-stopped".
120    #[serde(default)]
121    pub restart: Option<String>,
122
123    /// Custom DNS servers.
124    #[serde(default)]
125    pub dns: DnsConfig,
126
127    /// tmpfs mounts.
128    #[serde(default)]
129    pub tmpfs: StringOrList,
130
131    /// Linux capabilities to add.
132    #[serde(default)]
133    pub cap_add: Vec<String>,
134
135    /// Linux capabilities to drop.
136    #[serde(default)]
137    pub cap_drop: Vec<String>,
138
139    /// Privileged mode.
140    #[serde(default)]
141    pub privileged: bool,
142
143    /// Custom labels.
144    #[serde(default)]
145    pub labels: Labels,
146
147    /// Health check configuration.
148    #[serde(default)]
149    pub healthcheck: Option<HealthcheckConfig>,
150
151    /// Working directory inside the container.
152    #[serde(default)]
153    pub working_dir: Option<String>,
154
155    /// Hostname inside the container.
156    #[serde(default)]
157    pub hostname: Option<String>,
158
159    /// Static host entries (`HOST:IP`).
160    #[serde(default)]
161    pub extra_hosts: StringOrList,
162}
163
164/// Health check configuration for a service.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(deny_unknown_fields)]
167pub struct HealthcheckConfig {
168    /// Command to run (e.g., ["CMD", "curl", "-f", "http://localhost/"]).
169    #[serde(default)]
170    pub test: StringOrList,
171    /// Disable the image or service health check.
172    #[serde(default)]
173    pub disable: bool,
174    /// Interval between checks (e.g., "30s").
175    #[serde(default)]
176    pub interval: Option<String>,
177    /// Timeout for each check (e.g., "5s").
178    #[serde(default)]
179    pub timeout: Option<String>,
180    /// Number of retries before unhealthy.
181    #[serde(default)]
182    pub retries: Option<u32>,
183    /// Start period before health checks count (e.g., "10s").
184    #[serde(default)]
185    pub start_period: Option<String>,
186}
187
188/// Volume declaration.
189#[derive(Debug, Clone, Serialize, Deserialize, Default)]
190#[serde(deny_unknown_fields)]
191pub struct VolumeDeclaration {
192    /// Volume driver (default: "local").
193    #[serde(default)]
194    pub driver: Option<String>,
195}
196
197/// Network declaration.
198#[derive(Debug, Clone, Serialize, Deserialize, Default)]
199#[serde(deny_unknown_fields)]
200pub struct NetworkDeclaration {
201    /// Network driver (default: "bridge").
202    #[serde(default)]
203    pub driver: Option<String>,
204}
205
206/// A value that can be either a string or a list of strings.
207///
208/// Handles both `command: "echo hello"` and `command: ["echo", "hello"]`.
209#[derive(Debug, Clone, Serialize, Deserialize, Default)]
210#[serde(untagged)]
211pub enum StringOrList {
212    #[default]
213    Empty,
214    Single(String),
215    List(Vec<String>),
216}
217
218impl StringOrList {
219    /// Convert to a Vec<String>, splitting a single string on whitespace.
220    pub fn to_vec(&self) -> Vec<String> {
221        match self {
222            Self::Empty => vec![],
223            Self::Single(s) => s.split_whitespace().map(String::from).collect(),
224            Self::List(v) => v.clone(),
225        }
226    }
227
228    /// Returns true if empty.
229    pub fn is_empty(&self) -> bool {
230        match self {
231            Self::Empty => true,
232            Self::Single(s) => s.is_empty(),
233            Self::List(v) => v.is_empty(),
234        }
235    }
236}
237
238/// Environment variables: supports both map and list format.
239///
240/// Map: `environment: { KEY: value }`
241/// List: `environment: ["KEY=value"]`
242#[derive(Debug, Clone, Serialize, Deserialize, Default)]
243#[serde(untagged)]
244pub enum EnvVars {
245    #[default]
246    Empty,
247    Map(HashMap<String, String>),
248    List(Vec<String>),
249}
250
251impl EnvVars {
252    /// Convert to a list of (key, value) pairs.
253    pub fn to_pairs(&self) -> Vec<(String, String)> {
254        match self {
255            Self::Empty => vec![],
256            Self::Map(m) => {
257                let mut pairs = m
258                    .iter()
259                    .map(|(key, value)| (key.clone(), value.clone()))
260                    .collect::<Vec<_>>();
261                pairs.sort_by(|left, right| left.0.cmp(&right.0));
262                pairs
263            }
264            Self::List(list) => list
265                .iter()
266                .filter_map(|s| {
267                    let (k, v) = s.split_once('=')?;
268                    Some((k.to_string(), v.to_string()))
269                })
270                .collect(),
271        }
272    }
273}
274
275/// depends_on: supports both simple list and extended syntax.
276///
277/// Simple: `depends_on: [db, redis]`
278/// Extended: `depends_on: { db: { condition: service_healthy } }`
279#[derive(Debug, Clone, Serialize, Deserialize, Default)]
280#[serde(untagged)]
281pub enum DependsOn {
282    #[default]
283    Empty,
284    List(Vec<String>),
285    Map(HashMap<String, DependsOnCondition>),
286}
287
288impl DependsOn {
289    /// Get the list of dependency service names.
290    pub fn services(&self) -> Vec<String> {
291        match self {
292            Self::Empty => vec![],
293            Self::List(v) => v.clone(),
294            Self::Map(m) => {
295                let mut services = m.keys().cloned().collect::<Vec<_>>();
296                services.sort();
297                services
298            }
299        }
300    }
301}
302
303/// Condition for a depends_on entry.
304#[derive(Debug, Clone, Serialize, Deserialize)]
305#[serde(deny_unknown_fields)]
306pub struct DependsOnCondition {
307    /// Condition: "service_started" (default) or "service_healthy".
308    #[serde(default = "default_condition")]
309    pub condition: String,
310}
311
312fn default_condition() -> String {
313    "service_started".to_string()
314}
315
316/// Service networks: supports both list and map format.
317///
318/// List: `networks: [frontend, backend]`
319/// Map: `networks: { frontend: {} }`
320#[derive(Debug, Clone, Serialize, Deserialize, Default)]
321#[serde(untagged)]
322pub enum ServiceNetworks {
323    #[default]
324    Empty,
325    List(Vec<String>),
326    Map(HashMap<String, Option<ServiceNetworkConfig>>),
327}
328
329impl ServiceNetworks {
330    /// Get the list of network names.
331    pub fn names(&self) -> Vec<String> {
332        match self {
333            Self::Empty => vec![],
334            Self::List(v) => v.clone(),
335            Self::Map(m) => {
336                let mut names = m.keys().cloned().collect::<Vec<_>>();
337                names.sort();
338                names
339            }
340        }
341    }
342}
343
344/// Per-service network configuration.
345#[derive(Debug, Clone, Serialize, Deserialize, Default)]
346#[serde(deny_unknown_fields)]
347pub struct ServiceNetworkConfig {
348    /// Network aliases for this service.
349    #[serde(default)]
350    pub aliases: Vec<String>,
351}
352
353/// Labels: supports both map and list format.
354#[derive(Debug, Clone, Serialize, Deserialize, Default)]
355#[serde(untagged)]
356pub enum Labels {
357    #[default]
358    Empty,
359    Map(HashMap<String, String>),
360    List(Vec<String>),
361}
362
363impl Labels {
364    /// Convert labels to key/value pairs.
365    pub fn to_map(&self) -> HashMap<String, String> {
366        match self {
367            Self::Empty => HashMap::new(),
368            Self::Map(map) => map.clone(),
369            Self::List(list) => list
370                .iter()
371                .map(|entry| {
372                    let (key, value) = entry.split_once('=').unwrap_or((entry, ""));
373                    (key.to_string(), value.to_string())
374                })
375                .collect(),
376        }
377    }
378}
379
380/// DNS config: supports both single string and list.
381#[derive(Debug, Clone, Serialize, Deserialize, Default)]
382#[serde(untagged)]
383pub enum DnsConfig {
384    #[default]
385    Empty,
386    Single(String),
387    List(Vec<String>),
388}
389
390impl DnsConfig {
391    /// Convert to a list of DNS server addresses.
392    pub fn to_vec(&self) -> Vec<String> {
393        match self {
394            Self::Empty => vec![],
395            Self::Single(s) => vec![s.clone()],
396            Self::List(v) => v.clone(),
397        }
398    }
399}
400
401impl ComposeConfig {
402    /// Parse an A3S Compose ACL document using the process environment for
403    /// `env("NAME")` calls.
404    pub fn from_acl_str(source: &str) -> Result<Self, ComposeAclError> {
405        let environment = std::env::vars().collect();
406        Self::from_acl_str_with_environment(source, &environment)
407    }
408
409    /// Parse an A3S Compose ACL document using an explicit environment.
410    pub fn from_acl_str_with_environment(
411        source: &str,
412        environment: &HashMap<String, String>,
413    ) -> Result<Self, ComposeAclError> {
414        acl::parse_compose_acl(source, environment)
415    }
416
417    /// Parse a compose config from YAML bytes.
418    pub fn from_yaml(yaml: &[u8]) -> Result<Self, serde_yaml::Error> {
419        serde_yaml::from_slice(yaml)
420    }
421
422    /// Parse a compose config from a YAML string.
423    pub fn from_yaml_str(yaml: &str) -> Result<Self, serde_yaml::Error> {
424        serde_yaml::from_str(yaml)
425    }
426
427    /// Compute a topological ordering of services based on depends_on.
428    ///
429    /// Returns an error if there is a dependency cycle.
430    pub fn service_order(&self) -> Result<Vec<String>, String> {
431        let mut order = Vec::new();
432        // 0 = unvisited, 1 = in-progress, 2 = done
433        let mut state: HashMap<String, u8> = HashMap::new();
434
435        let mut service_names = self.services.keys().collect::<Vec<_>>();
436        service_names.sort();
437        for name in service_names {
438            if !state.contains_key(name) {
439                self.topo_visit(name, &mut state, &mut order)?;
440            }
441        }
442
443        Ok(order)
444    }
445
446    fn topo_visit(
447        &self,
448        name: &str,
449        state: &mut HashMap<String, u8>,
450        order: &mut Vec<String>,
451    ) -> Result<(), String> {
452        match state.get(name) {
453            Some(1) => {
454                return Err(format!(
455                    "Dependency cycle detected involving service '{}'",
456                    name
457                ));
458            }
459            Some(2) => return Ok(()), // already fully visited
460            _ => {}
461        }
462
463        state.insert(name.to_string(), 1); // in-progress
464
465        if let Some(svc) = self.services.get(name) {
466            let deps = svc.depends_on.services();
467            for dep in &deps {
468                if !self.services.contains_key(dep) {
469                    return Err(format!(
470                        "Service '{}' depends on '{}' which is not defined",
471                        name, dep
472                    ));
473                }
474                self.topo_visit(dep, state, order)?;
475            }
476        }
477
478        state.insert(name.to_string(), 2); // done
479        order.push(name.to_string());
480        Ok(())
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn test_parse_minimal_compose() {
490        let yaml = r#"
491services:
492  web:
493    image: nginx:latest
494"#;
495        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
496        assert_eq!(config.services.len(), 1);
497        assert_eq!(
498            config.services["web"].image.as_deref(),
499            Some("nginx:latest")
500        );
501    }
502
503    #[test]
504    fn test_raw_yaml_parser_never_silently_ignores_unknown_fields() {
505        let error = ComposeConfig::from_yaml_str(
506            "services:\n  api:\n    image: api:latest\n    build: .\n",
507        )
508        .unwrap_err();
509
510        assert!(error.to_string().contains("unknown field `build`"));
511    }
512
513    #[test]
514    fn test_parse_full_compose() {
515        let yaml = r#"
516version: "3"
517services:
518  web:
519    image: nginx:latest
520    ports:
521      - "8080:80"
522    depends_on:
523      - db
524    environment:
525      APP_ENV: production
526    volumes:
527      - "static:/usr/share/nginx/html"
528  db:
529    image: postgres:16
530    environment:
531      - POSTGRES_PASSWORD=secret
532    volumes:
533      - "pgdata:/var/lib/postgresql/data"
534    mem_limit: "1g"
535    cpus: 2
536volumes:
537  pgdata:
538  static:
539networks:
540  default:
541"#;
542        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
543        assert_eq!(config.services.len(), 2);
544        assert_eq!(config.volumes.len(), 2);
545        assert!(config.services["web"]
546            .depends_on
547            .services()
548            .contains(&"db".to_string()));
549        assert_eq!(config.services["web"].ports, vec!["8080:80"]);
550        assert_eq!(config.services["db"].cpus, Some(2));
551        assert_eq!(config.services["db"].mem_limit.as_deref(), Some("1g"));
552    }
553
554    #[test]
555    fn test_service_order_simple() {
556        let yaml = r#"
557services:
558  web:
559    image: nginx
560    depends_on: [api]
561  api:
562    image: myapi
563    depends_on: [db]
564  db:
565    image: postgres
566"#;
567        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
568        let order = config.service_order().unwrap();
569        let db_pos = order.iter().position(|s| s == "db").unwrap();
570        let api_pos = order.iter().position(|s| s == "api").unwrap();
571        let web_pos = order.iter().position(|s| s == "web").unwrap();
572        assert!(db_pos < api_pos);
573        assert!(api_pos < web_pos);
574    }
575
576    #[test]
577    fn test_service_order_cycle_detected() {
578        let yaml = r#"
579services:
580  a:
581    image: img
582    depends_on: [b]
583  b:
584    image: img
585    depends_on: [a]
586"#;
587        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
588        let result = config.service_order();
589        assert!(result.is_err());
590        assert!(result.unwrap_err().contains("cycle"));
591    }
592
593    #[test]
594    fn test_service_order_missing_dependency() {
595        let yaml = r#"
596services:
597  web:
598    image: nginx
599    depends_on: [nonexistent]
600"#;
601        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
602        let result = config.service_order();
603        assert!(result.is_err());
604        assert!(result.unwrap_err().contains("not defined"));
605    }
606
607    #[test]
608    fn test_service_order_no_deps() {
609        let yaml = r#"
610services:
611  a:
612    image: img
613  b:
614    image: img
615  c:
616    image: img
617"#;
618        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
619        let order = config.service_order().unwrap();
620        assert_eq!(order.len(), 3);
621    }
622
623    #[test]
624    fn test_env_vars_map() {
625        let yaml = r#"
626services:
627  web:
628    image: nginx
629    environment:
630      KEY1: val1
631      KEY2: val2
632"#;
633        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
634        let pairs = config.services["web"].environment.to_pairs();
635        assert_eq!(pairs.len(), 2);
636    }
637
638    #[test]
639    fn test_env_vars_list() {
640        let yaml = r#"
641services:
642  web:
643    image: nginx
644    environment:
645      - KEY1=val1
646      - KEY2=val2
647"#;
648        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
649        let pairs = config.services["web"].environment.to_pairs();
650        assert_eq!(pairs.len(), 2);
651        assert!(pairs.iter().any(|(k, v)| k == "KEY1" && v == "val1"));
652    }
653
654    #[test]
655    fn test_string_or_list_single() {
656        let sol = StringOrList::Single("echo hello world".to_string());
657        assert_eq!(sol.to_vec(), vec!["echo", "hello", "world"]);
658        assert!(!sol.is_empty());
659    }
660
661    #[test]
662    fn test_string_or_list_list() {
663        let sol = StringOrList::List(vec!["echo".into(), "hello world".into()]);
664        assert_eq!(sol.to_vec(), vec!["echo", "hello world"]);
665    }
666
667    #[test]
668    fn test_string_or_list_empty() {
669        let sol = StringOrList::Empty;
670        assert!(sol.is_empty());
671        assert!(sol.to_vec().is_empty());
672    }
673
674    #[test]
675    fn test_depends_on_list() {
676        let yaml = r#"
677services:
678  web:
679    image: nginx
680    depends_on:
681      - db
682      - redis
683"#;
684        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
685        let deps = config.services["web"].depends_on.services();
686        assert_eq!(deps.len(), 2);
687        assert!(deps.contains(&"db".to_string()));
688        assert!(deps.contains(&"redis".to_string()));
689    }
690
691    #[test]
692    fn test_depends_on_map() {
693        let yaml = r#"
694services:
695  web:
696    image: nginx
697    depends_on:
698      db:
699        condition: service_healthy
700"#;
701        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
702        let deps = config.services["web"].depends_on.services();
703        assert_eq!(deps, vec!["db"]);
704    }
705
706    #[test]
707    fn test_dns_config_single() {
708        let dns = DnsConfig::Single("8.8.8.8".to_string());
709        assert_eq!(dns.to_vec(), vec!["8.8.8.8"]);
710    }
711
712    #[test]
713    fn test_dns_config_list() {
714        let dns = DnsConfig::List(vec!["8.8.8.8".into(), "1.1.1.1".into()]);
715        assert_eq!(dns.to_vec().len(), 2);
716    }
717
718    #[test]
719    fn test_service_networks_list() {
720        let yaml = r#"
721services:
722  web:
723    image: nginx
724    networks:
725      - frontend
726      - backend
727"#;
728        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
729        let nets = config.services["web"].networks.names();
730        assert_eq!(nets.len(), 2);
731    }
732
733    #[test]
734    fn test_healthcheck_config() {
735        let yaml = r#"
736services:
737  web:
738    image: nginx
739    healthcheck:
740      test: ["CMD", "curl", "-f", "http://localhost/"]
741      interval: "30s"
742      timeout: "5s"
743      retries: 3
744"#;
745        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
746        let hc = config.services["web"].healthcheck.as_ref().unwrap();
747        assert_eq!(hc.retries, Some(3));
748        assert_eq!(hc.interval.as_deref(), Some("30s"));
749        assert!(!hc.disable);
750    }
751
752    #[test]
753    fn test_healthcheck_disable() {
754        let yaml = r#"
755services:
756  web:
757    image: nginx
758    healthcheck:
759      disable: true
760"#;
761        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
762        let hc = config.services["web"].healthcheck.as_ref().unwrap();
763        assert!(hc.disable);
764        assert!(hc.test.is_empty());
765    }
766
767    #[test]
768    fn test_compose_serde_roundtrip() {
769        let yaml = r#"
770version: "3"
771services:
772  web:
773    image: nginx:latest
774    ports:
775      - "8080:80"
776"#;
777        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
778        let serialized = serde_yaml::to_string(&config).unwrap();
779        let reparsed = ComposeConfig::from_yaml_str(&serialized).unwrap();
780        assert_eq!(reparsed.services.len(), 1);
781        assert_eq!(
782            reparsed.services["web"].image.as_deref(),
783            Some("nginx:latest")
784        );
785    }
786
787    #[test]
788    fn test_labels_map() {
789        let yaml = r#"
790services:
791  web:
792    image: nginx
793    labels:
794      com.example.env: production
795"#;
796        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
797        assert!(matches!(config.services["web"].labels, Labels::Map(_)));
798        assert_eq!(
799            config.services["web"]
800                .labels
801                .to_map()
802                .get("com.example.env")
803                .map(String::as_str),
804            Some("production")
805        );
806    }
807
808    #[test]
809    fn test_labels_list() {
810        let yaml = r#"
811services:
812  web:
813    image: nginx
814    labels:
815      - "com.example.env=production"
816      - "com.example.debug=true"
817      - "com.example.flag"
818"#;
819        let config = ComposeConfig::from_yaml_str(yaml).unwrap();
820        let labels = config.services["web"].labels.to_map();
821        assert_eq!(
822            labels.get("com.example.env").map(String::as_str),
823            Some("production")
824        );
825        assert_eq!(
826            labels.get("com.example.debug").map(String::as_str),
827            Some("true")
828        );
829        assert_eq!(labels.get("com.example.flag").map(String::as_str), Some(""));
830    }
831
832    #[test]
833    fn test_service_config_defaults() {
834        let svc = ServiceConfig::default();
835        assert!(svc.image.is_none());
836        assert!(svc.ports.is_empty());
837        assert!(svc.volumes.is_empty());
838        assert!(!svc.privileged);
839    }
840}