1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use a3s_box_core::compose::ComposeConfig;
10use a3s_box_core::config::{BoxConfig, ResourceConfig};
11use a3s_box_core::error::{BoxError, Result};
12use a3s_box_core::network::NetworkMode;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ProjectState {
17 Running,
19 Partial,
21 Stopped,
23}
24
25#[derive(Debug, Clone)]
27pub struct ComposeProject {
28 pub name: String,
30 pub config: ComposeConfig,
32 pub service_order: Vec<String>,
34 pub service_boxes: HashMap<String, String>,
36 pub networks: Vec<String>,
38 pub base_dir: PathBuf,
40}
41
42impl ComposeProject {
43 pub fn new(name: impl Into<String>, config: ComposeConfig) -> Result<Self> {
47 let base_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
48 Self::with_base_dir(name, config, base_dir)
49 }
50
51 pub fn with_base_dir(
53 name: impl Into<String>,
54 config: ComposeConfig,
55 base_dir: impl Into<PathBuf>,
56 ) -> Result<Self> {
57 let name = name.into();
58
59 for (svc_name, svc) in &config.services {
61 if svc.image.is_none() {
62 return Err(BoxError::ConfigError(format!(
63 "Service '{}' has no image specified",
64 svc_name
65 )));
66 }
67 a3s_box_core::normalize_port_maps(&svc.ports).map_err(|e| {
68 BoxError::ConfigError(format!(
69 "Service '{}' has invalid port mapping: {}",
70 svc_name, e
71 ))
72 })?;
73 validate_depends_on_conditions(svc_name, &svc.depends_on)?;
74 }
75
76 let service_order = config
78 .service_order()
79 .map_err(|e| BoxError::ConfigError(format!("Invalid compose config: {}", e)))?;
80
81 Ok(Self {
82 name,
83 config,
84 service_order,
85 service_boxes: HashMap::new(),
86 networks: Vec::new(),
87 base_dir: base_dir.into(),
88 })
89 }
90
91 pub fn state(&self) -> ProjectState {
93 if self.service_boxes.is_empty() {
94 ProjectState::Stopped
95 } else if self.service_boxes.len() == self.config.services.len() {
96 ProjectState::Running
97 } else {
98 ProjectState::Partial
99 }
100 }
101
102 pub fn build_box_config(
106 &self,
107 service_name: &str,
108 default_network: Option<&str>,
109 ) -> Result<BoxConfig> {
110 let svc = self.config.services.get(service_name).ok_or_else(|| {
111 BoxError::ConfigError(format!(
112 "Service '{}' not found in compose config",
113 service_name
114 ))
115 })?;
116
117 let image = svc.image.as_deref().ok_or_else(|| {
118 BoxError::ConfigError(format!("Service '{}' has no image", service_name))
119 })?;
120
121 let memory_mb = match &svc.mem_limit {
123 Some(mem_str) => parse_compose_memory(mem_str)?,
124 None => 512, };
126
127 let extra_env = self.service_env(svc)?;
129
130 let network_mode = {
132 let nets = svc.networks.names();
133 if !nets.is_empty() {
134 let net_name = format!("{}_{}", self.name, nets[0]);
136 NetworkMode::Bridge { network: net_name }
137 } else if let Some(default_net) = default_network {
138 NetworkMode::Bridge {
139 network: default_net.to_string(),
140 }
141 } else {
142 NetworkMode::Tsi
143 }
144 };
145
146 let cmd = svc.command.as_ref().map(|c| c.to_vec()).unwrap_or_default();
148 let entrypoint_override = svc.entrypoint.as_ref().and_then(|e| {
149 let v = e.to_vec();
150 if v.is_empty() {
151 None
152 } else {
153 Some(v)
154 }
155 });
156 if let Some(hostname) = svc.hostname.as_deref() {
157 a3s_box_core::dns::validate_hostname(hostname)
158 .map_err(|e| BoxError::ConfigError(format!("Invalid hostname: {e}")))?;
159 }
160 let add_hosts = svc.extra_hosts.to_vec();
161 a3s_box_core::dns::parse_add_host_entries(&add_hosts)
162 .map_err(|e| BoxError::ConfigError(format!("Invalid extra_hosts entry: {e}")))?;
163 let port_map = a3s_box_core::normalize_port_maps(&svc.ports).map_err(|e| {
164 BoxError::ConfigError(format!(
165 "Service '{}' has invalid port mapping: {}",
166 service_name, e
167 ))
168 })?;
169
170 let config = BoxConfig {
171 image: image.to_string(),
172 resources: ResourceConfig {
173 vcpus: svc.cpus.unwrap_or(2),
174 memory_mb,
175 ..Default::default()
176 },
177 cmd,
178 entrypoint_override,
179 workdir: svc.working_dir.clone(),
180 hostname: svc.hostname.clone(),
181 volumes: svc.volumes.clone(),
182 extra_env,
183 port_map,
184 dns: svc.dns.to_vec(),
185 add_hosts,
186 network: network_mode,
187 tmpfs: svc.tmpfs.to_vec(),
188 cap_add: svc.cap_add.clone(),
189 cap_drop: svc.cap_drop.clone(),
190 privileged: svc.privileged,
191 ..Default::default()
192 };
193
194 Ok(config)
195 }
196
197 fn service_env(
198 &self,
199 svc: &a3s_box_core::compose::ServiceConfig,
200 ) -> Result<Vec<(String, String)>> {
201 let mut env = Vec::new();
202 for env_file in svc.env_file.to_vec() {
203 let path = resolve_compose_path(&self.base_dir, &env_file);
204 let entries = a3s_box_core::env::parse_env_file(&path).map_err(|e| {
205 BoxError::ConfigError(format!("Invalid env_file '{}': {}", path.display(), e))
206 })?;
207 a3s_box_core::env::merge_env_pairs(&mut env, &entries);
208 }
209 let inline_env = svc.environment.to_pairs();
210 a3s_box_core::env::merge_env_pairs(&mut env, &inline_env);
211 Ok(env)
212 }
213
214 pub fn default_network_name(&self) -> String {
216 format!("{}_default", self.name)
217 }
218
219 pub fn required_networks(&self) -> Vec<String> {
221 let mut nets = vec![self.default_network_name()];
222
223 for net_name in self.config.networks.keys() {
225 let prefixed = format!("{}_{}", self.name, net_name);
226 if !nets.contains(&prefixed) {
227 nets.push(prefixed);
228 }
229 }
230
231 for svc in self.config.services.values() {
233 for net_name in svc.networks.names() {
234 let prefixed = format!("{}_{}", self.name, net_name);
235 if !nets.contains(&prefixed) {
236 nets.push(prefixed);
237 }
238 }
239 }
240
241 nets
242 }
243
244 pub fn register_service(&mut self, service_name: &str, box_id: String) {
246 self.service_boxes.insert(service_name.to_string(), box_id);
247 }
248
249 pub fn unregister_service(&mut self, service_name: &str) {
251 self.service_boxes.remove(service_name);
252 }
253
254 pub fn box_id(&self, service_name: &str) -> Option<&str> {
256 self.service_boxes.get(service_name).map(|s| s.as_str())
257 }
258
259 pub fn shutdown_order(&self) -> Vec<String> {
261 let mut order = self.service_order.clone();
262 order.reverse();
263 order
264 }
265
266 pub fn health_wait_deps(&self, service_name: &str) -> Vec<String> {
270 let Some(svc) = self.config.services.get(service_name) else {
271 return vec![];
272 };
273
274 match &svc.depends_on {
275 a3s_box_core::compose::DependsOn::Map(map) => map
276 .iter()
277 .filter(|(_, cond)| cond.condition == "service_healthy")
278 .map(|(name, _)| name.clone())
279 .collect(),
280 _ => vec![],
281 }
282 }
283
284 pub fn completed_wait_deps(&self, service_name: &str) -> Vec<String> {
287 let Some(svc) = self.config.services.get(service_name) else {
288 return vec![];
289 };
290
291 match &svc.depends_on {
292 a3s_box_core::compose::DependsOn::Map(map) => map
293 .iter()
294 .filter(|(_, cond)| cond.condition == "service_completed_successfully")
295 .map(|(name, _)| name.clone())
296 .collect(),
297 _ => vec![],
298 }
299 }
300
301 pub fn healthcheck(&self, service_name: &str) -> Option<HealthCheckSpec> {
303 let svc = self.config.services.get(service_name)?;
304 let hc = svc.healthcheck.as_ref()?;
305 if hc.disable {
306 return None;
307 }
308
309 let cmd = healthcheck_command(&hc.test)?;
310
311 Some(HealthCheckSpec {
312 cmd,
313 interval_secs: hc
314 .interval
315 .as_deref()
316 .and_then(parse_duration_secs)
317 .unwrap_or(30),
318 timeout_secs: hc
319 .timeout
320 .as_deref()
321 .and_then(parse_duration_secs)
322 .unwrap_or(30),
323 retries: hc.retries.unwrap_or(3),
324 start_period_secs: hc
325 .start_period
326 .as_deref()
327 .and_then(parse_duration_secs)
328 .unwrap_or(0),
329 })
330 }
331
332 pub fn healthcheck_disabled(&self, service_name: &str) -> bool {
334 self.config
335 .services
336 .get(service_name)
337 .and_then(|svc| svc.healthcheck.as_ref())
338 .is_some_and(|hc| {
339 hc.disable
340 || matches!(
341 &hc.test,
342 a3s_box_core::compose::StringOrList::List(items)
343 if items.first().is_some_and(|value| value.eq_ignore_ascii_case("NONE"))
344 )
345 || matches!(
346 &hc.test,
347 a3s_box_core::compose::StringOrList::Single(value)
348 if value.trim().eq_ignore_ascii_case("NONE")
349 )
350 })
351 }
352}
353
354#[derive(Debug, Clone)]
356pub struct HealthCheckSpec {
357 pub cmd: Vec<String>,
359 pub interval_secs: u64,
361 pub timeout_secs: u64,
363 pub retries: u32,
365 pub start_period_secs: u64,
367}
368
369fn parse_duration_secs(s: &str) -> Option<u64> {
371 let s = s.trim().to_lowercase();
372 if s.ends_with("ms") {
373 let n: u64 = s.trim_end_matches("ms").parse().ok()?;
374 Some(n.div_ceil(1000))
375 } else if s.ends_with('s') {
376 s.trim_end_matches('s').parse().ok()
377 } else if s.ends_with('m') {
378 let n: u64 = s.trim_end_matches('m').parse().ok()?;
379 Some(n * 60)
380 } else if s.ends_with('h') {
381 let n: u64 = s.trim_end_matches('h').parse().ok()?;
382 Some(n * 3600)
383 } else {
384 s.parse().ok()
386 }
387}
388
389fn healthcheck_command(test: &a3s_box_core::compose::StringOrList) -> Option<Vec<String>> {
390 use a3s_box_core::compose::StringOrList;
391
392 match test {
393 StringOrList::Empty => None,
394 StringOrList::Single(command) => {
395 let command = command.trim();
396 if command.is_empty() || command.eq_ignore_ascii_case("NONE") {
397 None
398 } else {
399 Some(vec![
400 "sh".to_string(),
401 "-c".to_string(),
402 command.to_string(),
403 ])
404 }
405 }
406 StringOrList::List(items) => {
407 let marker = items.first()?;
408 if marker.eq_ignore_ascii_case("NONE") {
409 return None;
410 }
411 if marker.eq_ignore_ascii_case("CMD") {
412 let cmd = items.get(1..)?.to_vec();
413 return (!cmd.is_empty()).then_some(cmd);
414 }
415 if marker.eq_ignore_ascii_case("CMD-SHELL") {
416 let shell_cmd = items.get(1..)?.join(" ");
417 return (!shell_cmd.is_empty()).then_some(vec![
418 "sh".to_string(),
419 "-c".to_string(),
420 shell_cmd,
421 ]);
422 }
423
424 Some(items.clone()).filter(|cmd| !cmd.is_empty())
425 }
426 }
427}
428
429fn validate_depends_on_conditions(
430 service_name: &str,
431 depends_on: &a3s_box_core::compose::DependsOn,
432) -> Result<()> {
433 let a3s_box_core::compose::DependsOn::Map(map) = depends_on else {
434 return Ok(());
435 };
436
437 for (dep_name, condition) in map {
438 match condition.condition.as_str() {
439 "service_started" | "service_healthy" | "service_completed_successfully" => {}
440 other => {
441 return Err(BoxError::ConfigError(format!(
442 "Service '{}' depends on '{}' with unsupported condition '{}' (supported: service_started, service_healthy, service_completed_successfully)",
443 service_name, dep_name, other
444 )));
445 }
446 }
447 }
448
449 Ok(())
450}
451
452fn parse_compose_memory(s: &str) -> Result<u32> {
454 let s = s.trim().to_lowercase();
455 let (num_str, multiplier) = if s.ends_with("gb") || s.ends_with('g') {
456 let n = s.trim_end_matches("gb").trim_end_matches('g');
457 (n, 1024u64)
458 } else if s.ends_with("mb") || s.ends_with('m') {
459 let n = s.trim_end_matches("mb").trim_end_matches('m');
460 (n, 1u64)
461 } else if s.ends_with("kb") || s.ends_with('k') {
462 let n = s.trim_end_matches("kb").trim_end_matches('k');
463 return n
465 .parse::<u64>()
466 .map(|v| v.div_ceil(1024) as u32)
467 .map_err(|_| BoxError::ConfigError(format!("Invalid memory value: {}", s)));
468 } else {
469 return s
471 .parse::<u64>()
472 .map(|v| v.div_ceil(1024 * 1024) as u32)
473 .map_err(|_| BoxError::ConfigError(format!("Invalid memory value: {}", s)));
474 };
475
476 let num: f64 = num_str
477 .parse()
478 .map_err(|_| BoxError::ConfigError(format!("Invalid memory value: {}", s)))?;
479
480 if !num.is_finite() || num < 0.0 {
485 return Err(BoxError::ConfigError(format!(
486 "Invalid memory value: {}",
487 s
488 )));
489 }
490 let mib = num * multiplier as f64;
491 if mib > u32::MAX as f64 {
492 return Err(BoxError::ConfigError(format!(
493 "memory value too large: {}",
494 s
495 )));
496 }
497 Ok(mib.round() as u32)
498}
499
500fn resolve_compose_path(base_dir: &Path, path: &str) -> PathBuf {
501 let path = PathBuf::from(path);
502 if path.is_absolute() {
503 path
504 } else {
505 base_dir.join(path)
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 fn sample_config() -> ComposeConfig {
514 let yaml = r#"
515services:
516 web:
517 image: nginx:latest
518 ports:
519 - "8080:80"
520 depends_on:
521 - api
522 api:
523 image: myapi:v1
524 depends_on:
525 - db
526 environment:
527 DATABASE_URL: postgres://db:5432/app
528 db:
529 image: postgres:16
530 volumes:
531 - "pgdata:/var/lib/postgresql/data"
532 mem_limit: "1g"
533 cpus: 2
534volumes:
535 pgdata:
536"#;
537 ComposeConfig::from_yaml_str(yaml).unwrap()
538 }
539
540 #[test]
541 fn test_compose_project_new() {
542 let config = sample_config();
543 let project = ComposeProject::new("myapp", config).unwrap();
544 assert_eq!(project.name, "myapp");
545 assert_eq!(project.service_order.len(), 3);
546 let db_pos = project
548 .service_order
549 .iter()
550 .position(|s| s == "db")
551 .unwrap();
552 let api_pos = project
553 .service_order
554 .iter()
555 .position(|s| s == "api")
556 .unwrap();
557 let web_pos = project
558 .service_order
559 .iter()
560 .position(|s| s == "web")
561 .unwrap();
562 assert!(db_pos < api_pos);
563 assert!(api_pos < web_pos);
564 }
565
566 #[test]
567 fn test_compose_project_state() {
568 let config = sample_config();
569 let mut project = ComposeProject::new("myapp", config).unwrap();
570 assert_eq!(project.state(), ProjectState::Stopped);
571
572 project.register_service("db", "box-1".to_string());
573 assert_eq!(project.state(), ProjectState::Partial);
574
575 project.register_service("api", "box-2".to_string());
576 project.register_service("web", "box-3".to_string());
577 assert_eq!(project.state(), ProjectState::Running);
578
579 project.unregister_service("web");
580 assert_eq!(project.state(), ProjectState::Partial);
581 }
582
583 #[test]
584 fn test_compose_project_box_id() {
585 let config = sample_config();
586 let mut project = ComposeProject::new("myapp", config).unwrap();
587 assert!(project.box_id("db").is_none());
588
589 project.register_service("db", "box-123".to_string());
590 assert_eq!(project.box_id("db"), Some("box-123"));
591 }
592
593 #[test]
594 fn test_compose_project_shutdown_order() {
595 let config = sample_config();
596 let project = ComposeProject::new("myapp", config).unwrap();
597 let shutdown = project.shutdown_order();
598 let web_pos = shutdown.iter().position(|s| s == "web").unwrap();
600 let api_pos = shutdown.iter().position(|s| s == "api").unwrap();
601 let db_pos = shutdown.iter().position(|s| s == "db").unwrap();
602 assert!(web_pos < api_pos);
603 assert!(api_pos < db_pos);
604 }
605
606 #[test]
607 fn test_compose_project_default_network() {
608 let config = sample_config();
609 let project = ComposeProject::new("myapp", config).unwrap();
610 assert_eq!(project.default_network_name(), "myapp_default");
611 }
612
613 #[test]
614 fn test_compose_project_required_networks() {
615 let yaml = r#"
616services:
617 web:
618 image: nginx
619 networks:
620 - frontend
621 api:
622 image: myapi
623 networks:
624 - frontend
625 - backend
626networks:
627 frontend:
628 backend:
629"#;
630 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
631 let project = ComposeProject::new("myapp", config).unwrap();
632 let nets = project.required_networks();
633 assert!(nets.contains(&"myapp_default".to_string()));
634 assert!(nets.contains(&"myapp_frontend".to_string()));
635 assert!(nets.contains(&"myapp_backend".to_string()));
636 }
637
638 #[test]
639 fn test_build_box_config_basic() {
640 let config = sample_config();
641 let project = ComposeProject::new("myapp", config).unwrap();
642 let box_config = project
643 .build_box_config("db", Some("myapp_default"))
644 .unwrap();
645
646 assert_eq!(box_config.image, "postgres:16");
647 assert_eq!(box_config.resources.vcpus, 2);
648 assert_eq!(box_config.resources.memory_mb, 1024);
649 assert_eq!(box_config.volumes, vec!["pgdata:/var/lib/postgresql/data"]);
650 }
651
652 #[test]
653 fn test_build_box_config_env() {
654 let config = sample_config();
655 let project = ComposeProject::new("myapp", config).unwrap();
656 let box_config = project
657 .build_box_config("api", Some("myapp_default"))
658 .unwrap();
659
660 assert!(box_config
661 .extra_env
662 .iter()
663 .any(|(k, v)| k == "DATABASE_URL" && v == "postgres://db:5432/app"));
664 }
665
666 #[test]
667 fn test_build_box_config_env_file_with_environment_override() {
668 let dir = tempfile::TempDir::new().unwrap();
669 std::fs::write(dir.path().join("app.env"), "FOO=file\nBAR=file\n").unwrap();
670 let yaml = r#"
671services:
672 api:
673 image: myapi
674 env_file:
675 - app.env
676 environment:
677 FOO: inline
678 BAZ: inline
679"#;
680 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
681 let project = ComposeProject::with_base_dir("myapp", config, dir.path()).unwrap();
682 let box_config = project
683 .build_box_config("api", Some("myapp_default"))
684 .unwrap();
685
686 assert_eq!(
687 box_config.extra_env,
688 vec![
689 ("FOO".to_string(), "inline".to_string()),
690 ("BAR".to_string(), "file".to_string()),
691 ("BAZ".to_string(), "inline".to_string())
692 ]
693 );
694 }
695
696 #[test]
697 fn test_build_box_config_missing_env_file_is_rejected() {
698 let dir = tempfile::TempDir::new().unwrap();
699 let yaml = r#"
700services:
701 api:
702 image: myapi
703 env_file: missing.env
704"#;
705 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
706 let project = ComposeProject::with_base_dir("myapp", config, dir.path()).unwrap();
707
708 let err = project
709 .build_box_config("api", Some("myapp_default"))
710 .unwrap_err();
711
712 assert!(err.to_string().contains("Invalid env_file"));
713 }
714
715 #[test]
716 fn test_build_box_config_working_dir() {
717 let yaml = r#"
718services:
719 worker:
720 image: myworker
721 working_dir: /srv/app
722"#;
723 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
724 let project = ComposeProject::new("myapp", config).unwrap();
725 let box_config = project
726 .build_box_config("worker", Some("myapp_default"))
727 .unwrap();
728
729 assert_eq!(box_config.workdir.as_deref(), Some("/srv/app"));
730 }
731
732 #[test]
733 fn test_build_box_config_hostname_and_extra_hosts() {
734 let yaml = r#"
735services:
736 web:
737 image: nginx
738 hostname: web-1
739 extra_hosts:
740 - "db.local:10.88.0.10"
741"#;
742 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
743 let project = ComposeProject::new("myapp", config).unwrap();
744 let box_config = project
745 .build_box_config("web", Some("myapp_default"))
746 .unwrap();
747
748 assert_eq!(box_config.hostname.as_deref(), Some("web-1"));
749 assert_eq!(box_config.add_hosts, vec!["db.local:10.88.0.10"]);
750 }
751
752 #[test]
753 fn test_build_box_config_rejects_invalid_extra_hosts() {
754 let yaml = r#"
755services:
756 web:
757 image: nginx
758 extra_hosts:
759 - "db.local:not-an-ip"
760"#;
761 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
762 let project = ComposeProject::new("myapp", config).unwrap();
763
764 let err = project
765 .build_box_config("web", Some("myapp_default"))
766 .unwrap_err();
767
768 assert!(err.to_string().contains("Invalid extra_hosts"));
769 }
770
771 #[test]
772 fn test_build_box_config_ports() {
773 let config = sample_config();
774 let project = ComposeProject::new("myapp", config).unwrap();
775 let box_config = project
776 .build_box_config("web", Some("myapp_default"))
777 .unwrap();
778
779 assert_eq!(box_config.port_map, vec!["8080:80"]);
780 }
781
782 #[test]
783 fn test_build_box_config_normalizes_tcp_port_suffix() {
784 let yaml = r#"
785services:
786 web:
787 image: nginx
788 ports:
789 - "8080:80/tcp"
790"#;
791 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
792 let project = ComposeProject::new("myapp", config).unwrap();
793 let box_config = project
794 .build_box_config("web", Some("myapp_default"))
795 .unwrap();
796
797 assert_eq!(box_config.port_map, vec!["8080:80"]);
798 }
799
800 #[test]
801 fn test_compose_project_rejects_udp_ports() {
802 let yaml = r#"
803services:
804 web:
805 image: nginx
806 ports:
807 - "8080:80/udp"
808"#;
809 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
810
811 let err = ComposeProject::new("myapp", config).unwrap_err();
812
813 assert!(err.to_string().contains("only TCP is supported"));
814 }
815
816 #[test]
817 fn test_build_box_config_network_mode() {
818 let config = sample_config();
819 let project = ComposeProject::new("myapp", config).unwrap();
820 let box_config = project
821 .build_box_config("web", Some("myapp_default"))
822 .unwrap();
823
824 assert!(matches!(
825 box_config.network,
826 NetworkMode::Bridge { ref network } if network == "myapp_default"
827 ));
828 }
829
830 #[test]
831 fn test_build_box_config_service_not_found() {
832 let config = sample_config();
833 let project = ComposeProject::new("myapp", config).unwrap();
834 let result = project.build_box_config("nonexistent", None);
835 assert!(result.is_err());
836 }
837
838 #[test]
839 fn test_build_box_config_no_network() {
840 let config = sample_config();
841 let project = ComposeProject::new("myapp", config).unwrap();
842 let box_config = project.build_box_config("web", None).unwrap();
843 assert!(matches!(box_config.network, NetworkMode::Tsi));
845 }
846
847 #[test]
848 fn test_compose_project_no_image_error() {
849 let yaml = r#"
850services:
851 web:
852 ports:
853 - "8080:80"
854"#;
855 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
856 let result = ComposeProject::new("myapp", config);
857 assert!(result.is_err());
858 assert!(result.unwrap_err().to_string().contains("no image"));
859 }
860
861 #[test]
862 fn test_parse_compose_memory_mb() {
863 assert_eq!(parse_compose_memory("512m").unwrap(), 512);
864 assert_eq!(parse_compose_memory("512M").unwrap(), 512);
865 assert_eq!(parse_compose_memory("512mb").unwrap(), 512);
866 }
867
868 #[test]
869 fn test_parse_compose_memory_rejects_negative_and_overflow() {
870 assert!(parse_compose_memory("-5g").is_err());
873 assert!(parse_compose_memory("99999999g").is_err());
874 assert_eq!(parse_compose_memory("1.5g").unwrap(), 1536);
876 }
877
878 #[test]
879 fn test_parse_compose_memory_gb() {
880 assert_eq!(parse_compose_memory("1g").unwrap(), 1024);
881 assert_eq!(parse_compose_memory("2G").unwrap(), 2048);
882 assert_eq!(parse_compose_memory("1.5g").unwrap(), 1536);
883 }
884
885 #[test]
886 fn test_parse_compose_memory_bytes() {
887 assert_eq!(parse_compose_memory("536870912").unwrap(), 512);
888 }
889
890 #[test]
891 fn test_parse_compose_memory_invalid() {
892 assert!(parse_compose_memory("abc").is_err());
893 }
894
895 #[test]
896 fn test_build_box_config_with_service_network() {
897 let yaml = r#"
898services:
899 web:
900 image: nginx
901 networks:
902 - frontend
903networks:
904 frontend:
905"#;
906 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
907 let project = ComposeProject::new("myapp", config).unwrap();
908 let box_config = project
909 .build_box_config("web", Some("myapp_default"))
910 .unwrap();
911
912 assert!(matches!(
914 box_config.network,
915 NetworkMode::Bridge { ref network } if network == "myapp_frontend"
916 ));
917 }
918
919 #[test]
920 fn test_build_box_config_privileged() {
921 let yaml = r#"
922services:
923 web:
924 image: nginx
925 privileged: true
926 cap_add:
927 - NET_ADMIN
928 cap_drop:
929 - ALL
930"#;
931 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
932 let project = ComposeProject::new("myapp", config).unwrap();
933 let box_config = project
934 .build_box_config("web", Some("myapp_default"))
935 .unwrap();
936
937 assert!(box_config.privileged);
938 assert_eq!(box_config.cap_add, vec!["NET_ADMIN"]);
939 assert_eq!(box_config.cap_drop, vec!["ALL"]);
940 }
941
942 #[test]
943 fn test_parse_duration_secs() {
944 assert_eq!(parse_duration_secs("30s"), Some(30));
945 assert_eq!(parse_duration_secs("1m"), Some(60));
946 assert_eq!(parse_duration_secs("2h"), Some(7200));
947 assert_eq!(parse_duration_secs("500ms"), Some(1));
948 assert_eq!(parse_duration_secs("5000ms"), Some(5));
949 assert_eq!(parse_duration_secs("10"), Some(10));
950 assert_eq!(parse_duration_secs("abc"), None);
951 }
952
953 #[test]
954 fn test_health_wait_deps_simple() {
955 let yaml = r#"
956services:
957 web:
958 image: nginx
959 depends_on:
960 - db
961 db:
962 image: postgres
963"#;
964 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
965 let project = ComposeProject::new("myapp", config).unwrap();
966 assert!(project.health_wait_deps("web").is_empty());
968 }
969
970 #[test]
971 fn test_health_wait_deps_service_healthy() {
972 let yaml = r#"
973services:
974 web:
975 image: nginx
976 depends_on:
977 db:
978 condition: service_healthy
979 redis:
980 condition: service_started
981 db:
982 image: postgres
983 healthcheck:
984 test: ["CMD", "pg_isready"]
985 interval: 10s
986 timeout: 5s
987 retries: 5
988 redis:
989 image: redis
990"#;
991 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
992 let project = ComposeProject::new("myapp", config).unwrap();
993 let deps = project.health_wait_deps("web");
994 assert_eq!(deps, vec!["db".to_string()]);
995 }
996
997 #[test]
998 fn test_healthcheck_spec() {
999 let yaml = r#"
1000services:
1001 web:
1002 image: nginx
1003 healthcheck:
1004 test: ["CMD", "curl", "-f", "http://localhost/"]
1005 interval: 10s
1006 timeout: 3s
1007 retries: 5
1008 start_period: 30s
1009"#;
1010 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1011 let project = ComposeProject::new("myapp", config).unwrap();
1012 let hc = project.healthcheck("web").unwrap();
1013 assert_eq!(hc.cmd, vec!["curl", "-f", "http://localhost/"]);
1014 assert_eq!(hc.interval_secs, 10);
1015 assert_eq!(hc.timeout_secs, 3);
1016 assert_eq!(hc.retries, 5);
1017 assert_eq!(hc.start_period_secs, 30);
1018 }
1019
1020 #[test]
1021 fn test_healthcheck_spec_defaults() {
1022 let yaml = r#"
1023services:
1024 web:
1025 image: nginx
1026 healthcheck:
1027 test: ["CMD", "true"]
1028"#;
1029 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1030 let project = ComposeProject::new("myapp", config).unwrap();
1031 let hc = project.healthcheck("web").unwrap();
1032 assert_eq!(hc.cmd, vec!["true"]);
1033 assert_eq!(hc.interval_secs, 30);
1034 assert_eq!(hc.timeout_secs, 30);
1035 assert_eq!(hc.retries, 3);
1036 assert_eq!(hc.start_period_secs, 0);
1037 }
1038
1039 #[test]
1040 fn test_healthcheck_cmd_shell() {
1041 let yaml = r#"
1042services:
1043 web:
1044 image: nginx
1045 healthcheck:
1046 test: ["CMD-SHELL", "curl -f http://localhost/ || exit 1"]
1047"#;
1048 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1049 let project = ComposeProject::new("myapp", config).unwrap();
1050 let hc = project.healthcheck("web").unwrap();
1051 assert_eq!(
1052 hc.cmd,
1053 vec!["sh", "-c", "curl -f http://localhost/ || exit 1"]
1054 );
1055 }
1056
1057 #[test]
1058 fn test_healthcheck_single_string_uses_shell() {
1059 let yaml = r#"
1060services:
1061 web:
1062 image: nginx
1063 healthcheck:
1064 test: curl -f http://localhost/ || exit 1
1065"#;
1066 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1067 let project = ComposeProject::new("myapp", config).unwrap();
1068 let hc = project.healthcheck("web").unwrap();
1069 assert_eq!(
1070 hc.cmd,
1071 vec!["sh", "-c", "curl -f http://localhost/ || exit 1"]
1072 );
1073 }
1074
1075 #[test]
1076 fn test_healthcheck_none_and_disable() {
1077 let yaml = r#"
1078services:
1079 none:
1080 image: nginx
1081 healthcheck:
1082 test: ["NONE"]
1083 disabled:
1084 image: redis
1085 healthcheck:
1086 disable: true
1087"#;
1088 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1089 let project = ComposeProject::new("myapp", config).unwrap();
1090 assert!(project.healthcheck("none").is_none());
1091 assert!(project.healthcheck_disabled("none"));
1092 assert!(project.healthcheck("disabled").is_none());
1093 assert!(project.healthcheck_disabled("disabled"));
1094 }
1095
1096 #[test]
1097 fn test_healthcheck_none() {
1098 let config = sample_config();
1099 let project = ComposeProject::new("myapp", config).unwrap();
1100 assert!(project.healthcheck("db").is_none());
1101 }
1102
1103 #[test]
1104 fn test_service_completed_successfully_condition_accepted() {
1105 let yaml = r#"
1106services:
1107 web:
1108 image: nginx
1109 depends_on:
1110 init:
1111 condition: service_completed_successfully
1112 init:
1113 image: busybox
1114"#;
1115 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1116 let project = ComposeProject::new("myapp", config).unwrap();
1117 assert_eq!(project.completed_wait_deps("web"), vec!["init".to_string()]);
1118 assert!(project.completed_wait_deps("init").is_empty());
1120 }
1121
1122 #[test]
1123 fn test_unsupported_depends_on_condition_rejected() {
1124 let yaml = r#"
1125services:
1126 web:
1127 image: nginx
1128 depends_on:
1129 db:
1130 condition: service_bogus_condition
1131 db:
1132 image: postgres
1133"#;
1134 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1135 let err = ComposeProject::new("myapp", config).unwrap_err();
1136 assert!(err.to_string().contains("unsupported condition"));
1137 }
1138}