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