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