Skip to main content

a3s_box_core/
compose.rs

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