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 Ok((num * multiplier as f64) as u32)
481}
482
483fn resolve_compose_path(base_dir: &Path, path: &str) -> PathBuf {
484 let path = PathBuf::from(path);
485 if path.is_absolute() {
486 path
487 } else {
488 base_dir.join(path)
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 fn sample_config() -> ComposeConfig {
497 let yaml = r#"
498services:
499 web:
500 image: nginx:latest
501 ports:
502 - "8080:80"
503 depends_on:
504 - api
505 api:
506 image: myapi:v1
507 depends_on:
508 - db
509 environment:
510 DATABASE_URL: postgres://db:5432/app
511 db:
512 image: postgres:16
513 volumes:
514 - "pgdata:/var/lib/postgresql/data"
515 mem_limit: "1g"
516 cpus: 2
517volumes:
518 pgdata:
519"#;
520 ComposeConfig::from_yaml_str(yaml).unwrap()
521 }
522
523 #[test]
524 fn test_compose_project_new() {
525 let config = sample_config();
526 let project = ComposeProject::new("myapp", config).unwrap();
527 assert_eq!(project.name, "myapp");
528 assert_eq!(project.service_order.len(), 3);
529 let db_pos = project
531 .service_order
532 .iter()
533 .position(|s| s == "db")
534 .unwrap();
535 let api_pos = project
536 .service_order
537 .iter()
538 .position(|s| s == "api")
539 .unwrap();
540 let web_pos = project
541 .service_order
542 .iter()
543 .position(|s| s == "web")
544 .unwrap();
545 assert!(db_pos < api_pos);
546 assert!(api_pos < web_pos);
547 }
548
549 #[test]
550 fn test_compose_project_state() {
551 let config = sample_config();
552 let mut project = ComposeProject::new("myapp", config).unwrap();
553 assert_eq!(project.state(), ProjectState::Stopped);
554
555 project.register_service("db", "box-1".to_string());
556 assert_eq!(project.state(), ProjectState::Partial);
557
558 project.register_service("api", "box-2".to_string());
559 project.register_service("web", "box-3".to_string());
560 assert_eq!(project.state(), ProjectState::Running);
561
562 project.unregister_service("web");
563 assert_eq!(project.state(), ProjectState::Partial);
564 }
565
566 #[test]
567 fn test_compose_project_box_id() {
568 let config = sample_config();
569 let mut project = ComposeProject::new("myapp", config).unwrap();
570 assert!(project.box_id("db").is_none());
571
572 project.register_service("db", "box-123".to_string());
573 assert_eq!(project.box_id("db"), Some("box-123"));
574 }
575
576 #[test]
577 fn test_compose_project_shutdown_order() {
578 let config = sample_config();
579 let project = ComposeProject::new("myapp", config).unwrap();
580 let shutdown = project.shutdown_order();
581 let web_pos = shutdown.iter().position(|s| s == "web").unwrap();
583 let api_pos = shutdown.iter().position(|s| s == "api").unwrap();
584 let db_pos = shutdown.iter().position(|s| s == "db").unwrap();
585 assert!(web_pos < api_pos);
586 assert!(api_pos < db_pos);
587 }
588
589 #[test]
590 fn test_compose_project_default_network() {
591 let config = sample_config();
592 let project = ComposeProject::new("myapp", config).unwrap();
593 assert_eq!(project.default_network_name(), "myapp_default");
594 }
595
596 #[test]
597 fn test_compose_project_required_networks() {
598 let yaml = r#"
599services:
600 web:
601 image: nginx
602 networks:
603 - frontend
604 api:
605 image: myapi
606 networks:
607 - frontend
608 - backend
609networks:
610 frontend:
611 backend:
612"#;
613 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
614 let project = ComposeProject::new("myapp", config).unwrap();
615 let nets = project.required_networks();
616 assert!(nets.contains(&"myapp_default".to_string()));
617 assert!(nets.contains(&"myapp_frontend".to_string()));
618 assert!(nets.contains(&"myapp_backend".to_string()));
619 }
620
621 #[test]
622 fn test_build_box_config_basic() {
623 let config = sample_config();
624 let project = ComposeProject::new("myapp", config).unwrap();
625 let box_config = project
626 .build_box_config("db", Some("myapp_default"))
627 .unwrap();
628
629 assert_eq!(box_config.image, "postgres:16");
630 assert_eq!(box_config.resources.vcpus, 2);
631 assert_eq!(box_config.resources.memory_mb, 1024);
632 assert_eq!(box_config.volumes, vec!["pgdata:/var/lib/postgresql/data"]);
633 }
634
635 #[test]
636 fn test_build_box_config_env() {
637 let config = sample_config();
638 let project = ComposeProject::new("myapp", config).unwrap();
639 let box_config = project
640 .build_box_config("api", Some("myapp_default"))
641 .unwrap();
642
643 assert!(box_config
644 .extra_env
645 .iter()
646 .any(|(k, v)| k == "DATABASE_URL" && v == "postgres://db:5432/app"));
647 }
648
649 #[test]
650 fn test_build_box_config_env_file_with_environment_override() {
651 let dir = tempfile::TempDir::new().unwrap();
652 std::fs::write(dir.path().join("app.env"), "FOO=file\nBAR=file\n").unwrap();
653 let yaml = r#"
654services:
655 api:
656 image: myapi
657 env_file:
658 - app.env
659 environment:
660 FOO: inline
661 BAZ: inline
662"#;
663 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
664 let project = ComposeProject::with_base_dir("myapp", config, dir.path()).unwrap();
665 let box_config = project
666 .build_box_config("api", Some("myapp_default"))
667 .unwrap();
668
669 assert_eq!(
670 box_config.extra_env,
671 vec![
672 ("FOO".to_string(), "inline".to_string()),
673 ("BAR".to_string(), "file".to_string()),
674 ("BAZ".to_string(), "inline".to_string())
675 ]
676 );
677 }
678
679 #[test]
680 fn test_build_box_config_missing_env_file_is_rejected() {
681 let dir = tempfile::TempDir::new().unwrap();
682 let yaml = r#"
683services:
684 api:
685 image: myapi
686 env_file: missing.env
687"#;
688 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
689 let project = ComposeProject::with_base_dir("myapp", config, dir.path()).unwrap();
690
691 let err = project
692 .build_box_config("api", Some("myapp_default"))
693 .unwrap_err();
694
695 assert!(err.to_string().contains("Invalid env_file"));
696 }
697
698 #[test]
699 fn test_build_box_config_working_dir() {
700 let yaml = r#"
701services:
702 worker:
703 image: myworker
704 working_dir: /srv/app
705"#;
706 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
707 let project = ComposeProject::new("myapp", config).unwrap();
708 let box_config = project
709 .build_box_config("worker", Some("myapp_default"))
710 .unwrap();
711
712 assert_eq!(box_config.workdir.as_deref(), Some("/srv/app"));
713 }
714
715 #[test]
716 fn test_build_box_config_hostname_and_extra_hosts() {
717 let yaml = r#"
718services:
719 web:
720 image: nginx
721 hostname: web-1
722 extra_hosts:
723 - "db.local:10.88.0.10"
724"#;
725 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
726 let project = ComposeProject::new("myapp", config).unwrap();
727 let box_config = project
728 .build_box_config("web", Some("myapp_default"))
729 .unwrap();
730
731 assert_eq!(box_config.hostname.as_deref(), Some("web-1"));
732 assert_eq!(box_config.add_hosts, vec!["db.local:10.88.0.10"]);
733 }
734
735 #[test]
736 fn test_build_box_config_rejects_invalid_extra_hosts() {
737 let yaml = r#"
738services:
739 web:
740 image: nginx
741 extra_hosts:
742 - "db.local:not-an-ip"
743"#;
744 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
745 let project = ComposeProject::new("myapp", config).unwrap();
746
747 let err = project
748 .build_box_config("web", Some("myapp_default"))
749 .unwrap_err();
750
751 assert!(err.to_string().contains("Invalid extra_hosts"));
752 }
753
754 #[test]
755 fn test_build_box_config_ports() {
756 let config = sample_config();
757 let project = ComposeProject::new("myapp", config).unwrap();
758 let box_config = project
759 .build_box_config("web", Some("myapp_default"))
760 .unwrap();
761
762 assert_eq!(box_config.port_map, vec!["8080:80"]);
763 }
764
765 #[test]
766 fn test_build_box_config_normalizes_tcp_port_suffix() {
767 let yaml = r#"
768services:
769 web:
770 image: nginx
771 ports:
772 - "8080:80/tcp"
773"#;
774 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
775 let project = ComposeProject::new("myapp", config).unwrap();
776 let box_config = project
777 .build_box_config("web", Some("myapp_default"))
778 .unwrap();
779
780 assert_eq!(box_config.port_map, vec!["8080:80"]);
781 }
782
783 #[test]
784 fn test_compose_project_rejects_udp_ports() {
785 let yaml = r#"
786services:
787 web:
788 image: nginx
789 ports:
790 - "8080:80/udp"
791"#;
792 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
793
794 let err = ComposeProject::new("myapp", config).unwrap_err();
795
796 assert!(err.to_string().contains("only TCP is supported"));
797 }
798
799 #[test]
800 fn test_build_box_config_network_mode() {
801 let config = sample_config();
802 let project = ComposeProject::new("myapp", config).unwrap();
803 let box_config = project
804 .build_box_config("web", Some("myapp_default"))
805 .unwrap();
806
807 assert!(matches!(
808 box_config.network,
809 NetworkMode::Bridge { ref network } if network == "myapp_default"
810 ));
811 }
812
813 #[test]
814 fn test_build_box_config_service_not_found() {
815 let config = sample_config();
816 let project = ComposeProject::new("myapp", config).unwrap();
817 let result = project.build_box_config("nonexistent", None);
818 assert!(result.is_err());
819 }
820
821 #[test]
822 fn test_build_box_config_no_network() {
823 let config = sample_config();
824 let project = ComposeProject::new("myapp", config).unwrap();
825 let box_config = project.build_box_config("web", None).unwrap();
826 assert!(matches!(box_config.network, NetworkMode::Tsi));
828 }
829
830 #[test]
831 fn test_compose_project_no_image_error() {
832 let yaml = r#"
833services:
834 web:
835 ports:
836 - "8080:80"
837"#;
838 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
839 let result = ComposeProject::new("myapp", config);
840 assert!(result.is_err());
841 assert!(result.unwrap_err().to_string().contains("no image"));
842 }
843
844 #[test]
845 fn test_parse_compose_memory_mb() {
846 assert_eq!(parse_compose_memory("512m").unwrap(), 512);
847 assert_eq!(parse_compose_memory("512M").unwrap(), 512);
848 assert_eq!(parse_compose_memory("512mb").unwrap(), 512);
849 }
850
851 #[test]
852 fn test_parse_compose_memory_gb() {
853 assert_eq!(parse_compose_memory("1g").unwrap(), 1024);
854 assert_eq!(parse_compose_memory("2G").unwrap(), 2048);
855 assert_eq!(parse_compose_memory("1.5g").unwrap(), 1536);
856 }
857
858 #[test]
859 fn test_parse_compose_memory_bytes() {
860 assert_eq!(parse_compose_memory("536870912").unwrap(), 512);
861 }
862
863 #[test]
864 fn test_parse_compose_memory_invalid() {
865 assert!(parse_compose_memory("abc").is_err());
866 }
867
868 #[test]
869 fn test_build_box_config_with_service_network() {
870 let yaml = r#"
871services:
872 web:
873 image: nginx
874 networks:
875 - frontend
876networks:
877 frontend:
878"#;
879 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
880 let project = ComposeProject::new("myapp", config).unwrap();
881 let box_config = project
882 .build_box_config("web", Some("myapp_default"))
883 .unwrap();
884
885 assert!(matches!(
887 box_config.network,
888 NetworkMode::Bridge { ref network } if network == "myapp_frontend"
889 ));
890 }
891
892 #[test]
893 fn test_build_box_config_privileged() {
894 let yaml = r#"
895services:
896 web:
897 image: nginx
898 privileged: true
899 cap_add:
900 - NET_ADMIN
901 cap_drop:
902 - ALL
903"#;
904 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
905 let project = ComposeProject::new("myapp", config).unwrap();
906 let box_config = project
907 .build_box_config("web", Some("myapp_default"))
908 .unwrap();
909
910 assert!(box_config.privileged);
911 assert_eq!(box_config.cap_add, vec!["NET_ADMIN"]);
912 assert_eq!(box_config.cap_drop, vec!["ALL"]);
913 }
914
915 #[test]
916 fn test_parse_duration_secs() {
917 assert_eq!(parse_duration_secs("30s"), Some(30));
918 assert_eq!(parse_duration_secs("1m"), Some(60));
919 assert_eq!(parse_duration_secs("2h"), Some(7200));
920 assert_eq!(parse_duration_secs("500ms"), Some(1));
921 assert_eq!(parse_duration_secs("5000ms"), Some(5));
922 assert_eq!(parse_duration_secs("10"), Some(10));
923 assert_eq!(parse_duration_secs("abc"), None);
924 }
925
926 #[test]
927 fn test_health_wait_deps_simple() {
928 let yaml = r#"
929services:
930 web:
931 image: nginx
932 depends_on:
933 - db
934 db:
935 image: postgres
936"#;
937 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
938 let project = ComposeProject::new("myapp", config).unwrap();
939 assert!(project.health_wait_deps("web").is_empty());
941 }
942
943 #[test]
944 fn test_health_wait_deps_service_healthy() {
945 let yaml = r#"
946services:
947 web:
948 image: nginx
949 depends_on:
950 db:
951 condition: service_healthy
952 redis:
953 condition: service_started
954 db:
955 image: postgres
956 healthcheck:
957 test: ["CMD", "pg_isready"]
958 interval: 10s
959 timeout: 5s
960 retries: 5
961 redis:
962 image: redis
963"#;
964 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
965 let project = ComposeProject::new("myapp", config).unwrap();
966 let deps = project.health_wait_deps("web");
967 assert_eq!(deps, vec!["db".to_string()]);
968 }
969
970 #[test]
971 fn test_healthcheck_spec() {
972 let yaml = r#"
973services:
974 web:
975 image: nginx
976 healthcheck:
977 test: ["CMD", "curl", "-f", "http://localhost/"]
978 interval: 10s
979 timeout: 3s
980 retries: 5
981 start_period: 30s
982"#;
983 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
984 let project = ComposeProject::new("myapp", config).unwrap();
985 let hc = project.healthcheck("web").unwrap();
986 assert_eq!(hc.cmd, vec!["curl", "-f", "http://localhost/"]);
987 assert_eq!(hc.interval_secs, 10);
988 assert_eq!(hc.timeout_secs, 3);
989 assert_eq!(hc.retries, 5);
990 assert_eq!(hc.start_period_secs, 30);
991 }
992
993 #[test]
994 fn test_healthcheck_spec_defaults() {
995 let yaml = r#"
996services:
997 web:
998 image: nginx
999 healthcheck:
1000 test: ["CMD", "true"]
1001"#;
1002 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1003 let project = ComposeProject::new("myapp", config).unwrap();
1004 let hc = project.healthcheck("web").unwrap();
1005 assert_eq!(hc.cmd, vec!["true"]);
1006 assert_eq!(hc.interval_secs, 30);
1007 assert_eq!(hc.timeout_secs, 30);
1008 assert_eq!(hc.retries, 3);
1009 assert_eq!(hc.start_period_secs, 0);
1010 }
1011
1012 #[test]
1013 fn test_healthcheck_cmd_shell() {
1014 let yaml = r#"
1015services:
1016 web:
1017 image: nginx
1018 healthcheck:
1019 test: ["CMD-SHELL", "curl -f http://localhost/ || exit 1"]
1020"#;
1021 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1022 let project = ComposeProject::new("myapp", config).unwrap();
1023 let hc = project.healthcheck("web").unwrap();
1024 assert_eq!(
1025 hc.cmd,
1026 vec!["sh", "-c", "curl -f http://localhost/ || exit 1"]
1027 );
1028 }
1029
1030 #[test]
1031 fn test_healthcheck_single_string_uses_shell() {
1032 let yaml = r#"
1033services:
1034 web:
1035 image: nginx
1036 healthcheck:
1037 test: curl -f http://localhost/ || exit 1
1038"#;
1039 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1040 let project = ComposeProject::new("myapp", config).unwrap();
1041 let hc = project.healthcheck("web").unwrap();
1042 assert_eq!(
1043 hc.cmd,
1044 vec!["sh", "-c", "curl -f http://localhost/ || exit 1"]
1045 );
1046 }
1047
1048 #[test]
1049 fn test_healthcheck_none_and_disable() {
1050 let yaml = r#"
1051services:
1052 none:
1053 image: nginx
1054 healthcheck:
1055 test: ["NONE"]
1056 disabled:
1057 image: redis
1058 healthcheck:
1059 disable: true
1060"#;
1061 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1062 let project = ComposeProject::new("myapp", config).unwrap();
1063 assert!(project.healthcheck("none").is_none());
1064 assert!(project.healthcheck_disabled("none"));
1065 assert!(project.healthcheck("disabled").is_none());
1066 assert!(project.healthcheck_disabled("disabled"));
1067 }
1068
1069 #[test]
1070 fn test_healthcheck_none() {
1071 let config = sample_config();
1072 let project = ComposeProject::new("myapp", config).unwrap();
1073 assert!(project.healthcheck("db").is_none());
1074 }
1075
1076 #[test]
1077 fn test_service_completed_successfully_condition_accepted() {
1078 let yaml = r#"
1079services:
1080 web:
1081 image: nginx
1082 depends_on:
1083 init:
1084 condition: service_completed_successfully
1085 init:
1086 image: busybox
1087"#;
1088 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1089 let project = ComposeProject::new("myapp", config).unwrap();
1090 assert_eq!(project.completed_wait_deps("web"), vec!["init".to_string()]);
1091 assert!(project.completed_wait_deps("init").is_empty());
1093 }
1094
1095 #[test]
1096 fn test_unsupported_depends_on_condition_rejected() {
1097 let yaml = r#"
1098services:
1099 web:
1100 image: nginx
1101 depends_on:
1102 db:
1103 condition: service_bogus_condition
1104 db:
1105 image: postgres
1106"#;
1107 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
1108 let err = ComposeProject::new("myapp", config).unwrap_err();
1109 assert!(err.to_string().contains("unsupported condition"));
1110 }
1111}