1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct ComposeConfig {
49 #[serde(default)]
51 pub version: Option<String>,
52
53 pub services: HashMap<String, ServiceConfig>,
55
56 #[serde(default)]
58 pub volumes: HashMap<String, Option<VolumeDeclaration>>,
59
60 #[serde(default)]
62 pub networks: HashMap<String, Option<NetworkDeclaration>>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, Default)]
67#[serde(deny_unknown_fields)]
68pub struct ServiceConfig {
69 #[serde(default)]
71 pub image: Option<String>,
72
73 #[serde(default)]
75 pub entrypoint: Option<StringOrList>,
76
77 #[serde(default)]
79 pub command: Option<StringOrList>,
80
81 #[serde(default)]
83 pub environment: EnvVars,
84
85 #[serde(default)]
87 pub env_file: StringOrList,
88
89 #[serde(default)]
91 pub ports: Vec<String>,
92
93 #[serde(default)]
95 pub volumes: Vec<String>,
96
97 #[serde(default)]
99 pub depends_on: DependsOn,
100
101 #[serde(default)]
103 pub networks: ServiceNetworks,
104
105 #[serde(default)]
107 pub cpus: Option<u32>,
108
109 #[serde(default)]
111 pub mem_limit: Option<String>,
112
113 #[serde(default)]
115 pub restart: Option<String>,
116
117 #[serde(default)]
119 pub dns: DnsConfig,
120
121 #[serde(default)]
123 pub tmpfs: StringOrList,
124
125 #[serde(default)]
127 pub cap_add: Vec<String>,
128
129 #[serde(default)]
131 pub cap_drop: Vec<String>,
132
133 #[serde(default)]
135 pub privileged: bool,
136
137 #[serde(default)]
139 pub labels: Labels,
140
141 #[serde(default)]
143 pub healthcheck: Option<HealthcheckConfig>,
144
145 #[serde(default)]
147 pub working_dir: Option<String>,
148
149 #[serde(default)]
151 pub hostname: Option<String>,
152
153 #[serde(default)]
155 pub extra_hosts: StringOrList,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct HealthcheckConfig {
162 #[serde(default)]
164 pub test: StringOrList,
165 #[serde(default)]
167 pub disable: bool,
168 #[serde(default)]
170 pub interval: Option<String>,
171 #[serde(default)]
173 pub timeout: Option<String>,
174 #[serde(default)]
176 pub retries: Option<u32>,
177 #[serde(default)]
179 pub start_period: Option<String>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, Default)]
184#[serde(deny_unknown_fields)]
185pub struct VolumeDeclaration {
186 #[serde(default)]
188 pub driver: Option<String>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, Default)]
193#[serde(deny_unknown_fields)]
194pub struct NetworkDeclaration {
195 #[serde(default)]
197 pub driver: Option<String>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, Default)]
204#[serde(untagged)]
205pub enum StringOrList {
206 #[default]
207 Empty,
208 Single(String),
209 List(Vec<String>),
210}
211
212impl StringOrList {
213 pub fn to_vec(&self) -> Vec<String> {
215 match self {
216 Self::Empty => vec![],
217 Self::Single(s) => s.split_whitespace().map(String::from).collect(),
218 Self::List(v) => v.clone(),
219 }
220 }
221
222 pub fn is_empty(&self) -> bool {
224 match self {
225 Self::Empty => true,
226 Self::Single(s) => s.is_empty(),
227 Self::List(v) => v.is_empty(),
228 }
229 }
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, Default)]
237#[serde(untagged)]
238pub enum EnvVars {
239 #[default]
240 Empty,
241 Map(HashMap<String, String>),
242 List(Vec<String>),
243}
244
245impl EnvVars {
246 pub fn to_pairs(&self) -> Vec<(String, String)> {
248 match self {
249 Self::Empty => vec![],
250 Self::Map(m) => {
251 let mut pairs = m
252 .iter()
253 .map(|(key, value)| (key.clone(), value.clone()))
254 .collect::<Vec<_>>();
255 pairs.sort_by(|left, right| left.0.cmp(&right.0));
256 pairs
257 }
258 Self::List(list) => list
259 .iter()
260 .filter_map(|s| {
261 let (k, v) = s.split_once('=')?;
262 Some((k.to_string(), v.to_string()))
263 })
264 .collect(),
265 }
266 }
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, Default)]
274#[serde(untagged)]
275pub enum DependsOn {
276 #[default]
277 Empty,
278 List(Vec<String>),
279 Map(HashMap<String, DependsOnCondition>),
280}
281
282impl DependsOn {
283 pub fn services(&self) -> Vec<String> {
285 match self {
286 Self::Empty => vec![],
287 Self::List(v) => v.clone(),
288 Self::Map(m) => {
289 let mut services = m.keys().cloned().collect::<Vec<_>>();
290 services.sort();
291 services
292 }
293 }
294 }
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct DependsOnCondition {
301 #[serde(default = "default_condition")]
303 pub condition: String,
304}
305
306fn default_condition() -> String {
307 "service_started".to_string()
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize, Default)]
315#[serde(untagged)]
316pub enum ServiceNetworks {
317 #[default]
318 Empty,
319 List(Vec<String>),
320 Map(HashMap<String, Option<ServiceNetworkConfig>>),
321}
322
323impl ServiceNetworks {
324 pub fn names(&self) -> Vec<String> {
326 match self {
327 Self::Empty => vec![],
328 Self::List(v) => v.clone(),
329 Self::Map(m) => {
330 let mut names = m.keys().cloned().collect::<Vec<_>>();
331 names.sort();
332 names
333 }
334 }
335 }
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, Default)]
340#[serde(deny_unknown_fields)]
341pub struct ServiceNetworkConfig {
342 #[serde(default)]
344 pub aliases: Vec<String>,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, Default)]
349#[serde(untagged)]
350pub enum Labels {
351 #[default]
352 Empty,
353 Map(HashMap<String, String>),
354 List(Vec<String>),
355}
356
357impl Labels {
358 pub fn to_map(&self) -> HashMap<String, String> {
360 match self {
361 Self::Empty => HashMap::new(),
362 Self::Map(map) => map.clone(),
363 Self::List(list) => list
364 .iter()
365 .map(|entry| {
366 let (key, value) = entry.split_once('=').unwrap_or((entry, ""));
367 (key.to_string(), value.to_string())
368 })
369 .collect(),
370 }
371 }
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize, Default)]
376#[serde(untagged)]
377pub enum DnsConfig {
378 #[default]
379 Empty,
380 Single(String),
381 List(Vec<String>),
382}
383
384impl DnsConfig {
385 pub fn to_vec(&self) -> Vec<String> {
387 match self {
388 Self::Empty => vec![],
389 Self::Single(s) => vec![s.clone()],
390 Self::List(v) => v.clone(),
391 }
392 }
393}
394
395impl ComposeConfig {
396 pub fn from_acl_str(source: &str) -> Result<Self, ComposeAclError> {
399 let environment = std::env::vars().collect();
400 Self::from_acl_str_with_environment(source, &environment)
401 }
402
403 pub fn from_acl_str_with_environment(
405 source: &str,
406 environment: &HashMap<String, String>,
407 ) -> Result<Self, ComposeAclError> {
408 acl::parse_compose_acl(source, environment)
409 }
410
411 pub fn from_yaml(yaml: &[u8]) -> Result<Self, serde_yaml::Error> {
413 serde_yaml::from_slice(yaml)
414 }
415
416 pub fn from_yaml_str(yaml: &str) -> Result<Self, serde_yaml::Error> {
418 serde_yaml::from_str(yaml)
419 }
420
421 pub fn service_order(&self) -> Result<Vec<String>, String> {
425 let mut order = Vec::new();
426 let mut state: HashMap<String, u8> = HashMap::new();
428
429 let mut service_names = self.services.keys().collect::<Vec<_>>();
430 service_names.sort();
431 for name in service_names {
432 if !state.contains_key(name) {
433 self.topo_visit(name, &mut state, &mut order)?;
434 }
435 }
436
437 Ok(order)
438 }
439
440 fn topo_visit(
441 &self,
442 name: &str,
443 state: &mut HashMap<String, u8>,
444 order: &mut Vec<String>,
445 ) -> Result<(), String> {
446 match state.get(name) {
447 Some(1) => {
448 return Err(format!(
449 "Dependency cycle detected involving service '{}'",
450 name
451 ));
452 }
453 Some(2) => return Ok(()), _ => {}
455 }
456
457 state.insert(name.to_string(), 1); if let Some(svc) = self.services.get(name) {
460 let deps = svc.depends_on.services();
461 for dep in &deps {
462 if !self.services.contains_key(dep) {
463 return Err(format!(
464 "Service '{}' depends on '{}' which is not defined",
465 name, dep
466 ));
467 }
468 self.topo_visit(dep, state, order)?;
469 }
470 }
471
472 state.insert(name.to_string(), 2); order.push(name.to_string());
474 Ok(())
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn test_parse_minimal_compose() {
484 let yaml = r#"
485services:
486 web:
487 image: nginx:latest
488"#;
489 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
490 assert_eq!(config.services.len(), 1);
491 assert_eq!(
492 config.services["web"].image.as_deref(),
493 Some("nginx:latest")
494 );
495 }
496
497 #[test]
498 fn test_raw_yaml_parser_never_silently_ignores_unknown_fields() {
499 let error = ComposeConfig::from_yaml_str(
500 "services:\n api:\n image: api:latest\n build: .\n",
501 )
502 .unwrap_err();
503
504 assert!(error.to_string().contains("unknown field `build`"));
505 }
506
507 #[test]
508 fn test_parse_full_compose() {
509 let yaml = r#"
510version: "3"
511services:
512 web:
513 image: nginx:latest
514 ports:
515 - "8080:80"
516 depends_on:
517 - db
518 environment:
519 APP_ENV: production
520 volumes:
521 - "static:/usr/share/nginx/html"
522 db:
523 image: postgres:16
524 environment:
525 - POSTGRES_PASSWORD=secret
526 volumes:
527 - "pgdata:/var/lib/postgresql/data"
528 mem_limit: "1g"
529 cpus: 2
530volumes:
531 pgdata:
532 static:
533networks:
534 default:
535"#;
536 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
537 assert_eq!(config.services.len(), 2);
538 assert_eq!(config.volumes.len(), 2);
539 assert!(config.services["web"]
540 .depends_on
541 .services()
542 .contains(&"db".to_string()));
543 assert_eq!(config.services["web"].ports, vec!["8080:80"]);
544 assert_eq!(config.services["db"].cpus, Some(2));
545 assert_eq!(config.services["db"].mem_limit.as_deref(), Some("1g"));
546 }
547
548 #[test]
549 fn test_service_order_simple() {
550 let yaml = r#"
551services:
552 web:
553 image: nginx
554 depends_on: [api]
555 api:
556 image: myapi
557 depends_on: [db]
558 db:
559 image: postgres
560"#;
561 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
562 let order = config.service_order().unwrap();
563 let db_pos = order.iter().position(|s| s == "db").unwrap();
564 let api_pos = order.iter().position(|s| s == "api").unwrap();
565 let web_pos = order.iter().position(|s| s == "web").unwrap();
566 assert!(db_pos < api_pos);
567 assert!(api_pos < web_pos);
568 }
569
570 #[test]
571 fn test_service_order_cycle_detected() {
572 let yaml = r#"
573services:
574 a:
575 image: img
576 depends_on: [b]
577 b:
578 image: img
579 depends_on: [a]
580"#;
581 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
582 let result = config.service_order();
583 assert!(result.is_err());
584 assert!(result.unwrap_err().contains("cycle"));
585 }
586
587 #[test]
588 fn test_service_order_missing_dependency() {
589 let yaml = r#"
590services:
591 web:
592 image: nginx
593 depends_on: [nonexistent]
594"#;
595 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
596 let result = config.service_order();
597 assert!(result.is_err());
598 assert!(result.unwrap_err().contains("not defined"));
599 }
600
601 #[test]
602 fn test_service_order_no_deps() {
603 let yaml = r#"
604services:
605 a:
606 image: img
607 b:
608 image: img
609 c:
610 image: img
611"#;
612 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
613 let order = config.service_order().unwrap();
614 assert_eq!(order.len(), 3);
615 }
616
617 #[test]
618 fn test_env_vars_map() {
619 let yaml = r#"
620services:
621 web:
622 image: nginx
623 environment:
624 KEY1: val1
625 KEY2: val2
626"#;
627 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
628 let pairs = config.services["web"].environment.to_pairs();
629 assert_eq!(pairs.len(), 2);
630 }
631
632 #[test]
633 fn test_env_vars_list() {
634 let yaml = r#"
635services:
636 web:
637 image: nginx
638 environment:
639 - KEY1=val1
640 - KEY2=val2
641"#;
642 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
643 let pairs = config.services["web"].environment.to_pairs();
644 assert_eq!(pairs.len(), 2);
645 assert!(pairs.iter().any(|(k, v)| k == "KEY1" && v == "val1"));
646 }
647
648 #[test]
649 fn test_string_or_list_single() {
650 let sol = StringOrList::Single("echo hello world".to_string());
651 assert_eq!(sol.to_vec(), vec!["echo", "hello", "world"]);
652 assert!(!sol.is_empty());
653 }
654
655 #[test]
656 fn test_string_or_list_list() {
657 let sol = StringOrList::List(vec!["echo".into(), "hello world".into()]);
658 assert_eq!(sol.to_vec(), vec!["echo", "hello world"]);
659 }
660
661 #[test]
662 fn test_string_or_list_empty() {
663 let sol = StringOrList::Empty;
664 assert!(sol.is_empty());
665 assert!(sol.to_vec().is_empty());
666 }
667
668 #[test]
669 fn test_depends_on_list() {
670 let yaml = r#"
671services:
672 web:
673 image: nginx
674 depends_on:
675 - db
676 - redis
677"#;
678 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
679 let deps = config.services["web"].depends_on.services();
680 assert_eq!(deps.len(), 2);
681 assert!(deps.contains(&"db".to_string()));
682 assert!(deps.contains(&"redis".to_string()));
683 }
684
685 #[test]
686 fn test_depends_on_map() {
687 let yaml = r#"
688services:
689 web:
690 image: nginx
691 depends_on:
692 db:
693 condition: service_healthy
694"#;
695 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
696 let deps = config.services["web"].depends_on.services();
697 assert_eq!(deps, vec!["db"]);
698 }
699
700 #[test]
701 fn test_dns_config_single() {
702 let dns = DnsConfig::Single("8.8.8.8".to_string());
703 assert_eq!(dns.to_vec(), vec!["8.8.8.8"]);
704 }
705
706 #[test]
707 fn test_dns_config_list() {
708 let dns = DnsConfig::List(vec!["8.8.8.8".into(), "1.1.1.1".into()]);
709 assert_eq!(dns.to_vec().len(), 2);
710 }
711
712 #[test]
713 fn test_service_networks_list() {
714 let yaml = r#"
715services:
716 web:
717 image: nginx
718 networks:
719 - frontend
720 - backend
721"#;
722 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
723 let nets = config.services["web"].networks.names();
724 assert_eq!(nets.len(), 2);
725 }
726
727 #[test]
728 fn test_healthcheck_config() {
729 let yaml = r#"
730services:
731 web:
732 image: nginx
733 healthcheck:
734 test: ["CMD", "curl", "-f", "http://localhost/"]
735 interval: "30s"
736 timeout: "5s"
737 retries: 3
738"#;
739 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
740 let hc = config.services["web"].healthcheck.as_ref().unwrap();
741 assert_eq!(hc.retries, Some(3));
742 assert_eq!(hc.interval.as_deref(), Some("30s"));
743 assert!(!hc.disable);
744 }
745
746 #[test]
747 fn test_healthcheck_disable() {
748 let yaml = r#"
749services:
750 web:
751 image: nginx
752 healthcheck:
753 disable: true
754"#;
755 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
756 let hc = config.services["web"].healthcheck.as_ref().unwrap();
757 assert!(hc.disable);
758 assert!(hc.test.is_empty());
759 }
760
761 #[test]
762 fn test_compose_serde_roundtrip() {
763 let yaml = r#"
764version: "3"
765services:
766 web:
767 image: nginx:latest
768 ports:
769 - "8080:80"
770"#;
771 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
772 let serialized = serde_yaml::to_string(&config).unwrap();
773 let reparsed = ComposeConfig::from_yaml_str(&serialized).unwrap();
774 assert_eq!(reparsed.services.len(), 1);
775 assert_eq!(
776 reparsed.services["web"].image.as_deref(),
777 Some("nginx:latest")
778 );
779 }
780
781 #[test]
782 fn test_labels_map() {
783 let yaml = r#"
784services:
785 web:
786 image: nginx
787 labels:
788 com.example.env: production
789"#;
790 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
791 assert!(matches!(config.services["web"].labels, Labels::Map(_)));
792 assert_eq!(
793 config.services["web"]
794 .labels
795 .to_map()
796 .get("com.example.env")
797 .map(String::as_str),
798 Some("production")
799 );
800 }
801
802 #[test]
803 fn test_labels_list() {
804 let yaml = r#"
805services:
806 web:
807 image: nginx
808 labels:
809 - "com.example.env=production"
810 - "com.example.debug=true"
811 - "com.example.flag"
812"#;
813 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
814 let labels = config.services["web"].labels.to_map();
815 assert_eq!(
816 labels.get("com.example.env").map(String::as_str),
817 Some("production")
818 );
819 assert_eq!(
820 labels.get("com.example.debug").map(String::as_str),
821 Some("true")
822 );
823 assert_eq!(labels.get("com.example.flag").map(String::as_str), Some(""));
824 }
825
826 #[test]
827 fn test_service_config_defaults() {
828 let svc = ServiceConfig::default();
829 assert!(svc.image.is_none());
830 assert!(svc.ports.is_empty());
831 assert!(svc.volumes.is_empty());
832 assert!(!svc.privileged);
833 }
834}