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)]
93 pub secret_environment: HashMap<String, String>,
94
95 #[serde(default)]
97 pub ports: Vec<String>,
98
99 #[serde(default)]
101 pub volumes: Vec<String>,
102
103 #[serde(default)]
105 pub depends_on: DependsOn,
106
107 #[serde(default)]
109 pub networks: ServiceNetworks,
110
111 #[serde(default)]
113 pub cpus: Option<u32>,
114
115 #[serde(default)]
117 pub mem_limit: Option<String>,
118
119 #[serde(default)]
121 pub restart: Option<String>,
122
123 #[serde(default)]
125 pub dns: DnsConfig,
126
127 #[serde(default)]
129 pub tmpfs: StringOrList,
130
131 #[serde(default)]
133 pub cap_add: Vec<String>,
134
135 #[serde(default)]
137 pub cap_drop: Vec<String>,
138
139 #[serde(default)]
141 pub privileged: bool,
142
143 #[serde(default)]
145 pub labels: Labels,
146
147 #[serde(default)]
149 pub healthcheck: Option<HealthcheckConfig>,
150
151 #[serde(default)]
153 pub working_dir: Option<String>,
154
155 #[serde(default)]
157 pub hostname: Option<String>,
158
159 #[serde(default)]
161 pub extra_hosts: StringOrList,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(deny_unknown_fields)]
167pub struct HealthcheckConfig {
168 #[serde(default)]
170 pub test: StringOrList,
171 #[serde(default)]
173 pub disable: bool,
174 #[serde(default)]
176 pub interval: Option<String>,
177 #[serde(default)]
179 pub timeout: Option<String>,
180 #[serde(default)]
182 pub retries: Option<u32>,
183 #[serde(default)]
185 pub start_period: Option<String>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, Default)]
190#[serde(deny_unknown_fields)]
191pub struct VolumeDeclaration {
192 #[serde(default)]
194 pub driver: Option<String>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, Default)]
199#[serde(deny_unknown_fields)]
200pub struct NetworkDeclaration {
201 #[serde(default)]
203 pub driver: Option<String>,
204}
205
206#[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 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 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#[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 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#[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
305#[serde(deny_unknown_fields)]
306pub struct DependsOnCondition {
307 #[serde(default = "default_condition")]
309 pub condition: String,
310}
311
312fn default_condition() -> String {
313 "service_started".to_string()
314}
315
316#[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 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
346#[serde(deny_unknown_fields)]
347pub struct ServiceNetworkConfig {
348 #[serde(default)]
350 pub aliases: Vec<String>,
351}
352
353#[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 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#[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 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 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 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 pub fn from_yaml(yaml: &[u8]) -> Result<Self, serde_yaml::Error> {
419 serde_yaml::from_slice(yaml)
420 }
421
422 pub fn from_yaml_str(yaml: &str) -> Result<Self, serde_yaml::Error> {
424 serde_yaml::from_str(yaml)
425 }
426
427 pub fn service_order(&self) -> Result<Vec<String>, String> {
431 let mut order = Vec::new();
432 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(()), _ => {}
461 }
462
463 state.insert(name.to_string(), 1); 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); 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}