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