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