Skip to main content

a3s_box_runtime/
compose.rs

1//! Compose orchestrator for multi-container workloads.
2//!
3//! Coordinates the lifecycle of multiple services defined in a compose file:
4//! network creation, dependency-ordered boot, and grouped teardown.
5
6use 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/// State of a compose project (group of services).
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ProjectState {
17    /// All services are running.
18    Running,
19    /// Some services are running.
20    Partial,
21    /// All services are stopped.
22    Stopped,
23}
24
25/// A running compose project.
26#[derive(Debug, Clone)]
27pub struct ComposeProject {
28    /// Project name (derived from directory name or --project-name).
29    pub name: String,
30    /// The parsed compose config.
31    pub config: ComposeConfig,
32    /// Service boot order (topologically sorted).
33    pub service_order: Vec<String>,
34    /// Map of service name → box ID (once started).
35    pub service_boxes: HashMap<String, String>,
36    /// Networks created for this project.
37    pub networks: Vec<String>,
38    /// Base directory used to resolve relative env_file paths.
39    pub base_dir: PathBuf,
40}
41
42impl ComposeProject {
43    /// Create a new compose project from a config.
44    ///
45    /// Validates the config and computes the service boot order.
46    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    /// Create a compose project with an explicit base directory.
52    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        // Validate: every service must have an image
60        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        // Compute topological order
77        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    /// Get the project state based on which services have box IDs.
92    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    /// Build a BoxConfig for a single service.
103    ///
104    /// Translates compose service fields into the BoxConfig used by VmManager.
105    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        // Parse memory limit
122        let memory_mb = match &svc.mem_limit {
123            Some(mem_str) => parse_compose_memory(mem_str)?,
124            None => 512, // default
125        };
126
127        // Build environment: env_file first, environment overrides.
128        let extra_env = self.service_env(svc)?;
129
130        // Determine network mode
131        let network_mode = {
132            let nets = svc.networks.names();
133            if !nets.is_empty() {
134                // Use the first declared network, prefixed with project name
135                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        // Build command and entrypoint
147        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    /// Get the network name for this project's default network.
215    pub fn default_network_name(&self) -> String {
216        format!("{}_default", self.name)
217    }
218
219    /// Get all network names this project needs (project-prefixed).
220    pub fn required_networks(&self) -> Vec<String> {
221        let mut nets = vec![self.default_network_name()];
222
223        // Add explicitly declared networks
224        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        // Add networks referenced by services
232        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    /// Record that a service has been started with the given box ID.
245    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    /// Remove a service's box ID (on stop/destroy).
250    pub fn unregister_service(&mut self, service_name: &str) {
251        self.service_boxes.remove(service_name);
252    }
253
254    /// Get the box ID for a service, if running.
255    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    /// Get the shutdown order (reverse of boot order).
260    pub fn shutdown_order(&self) -> Vec<String> {
261        let mut order = self.service_order.clone();
262        order.reverse();
263        order
264    }
265
266    /// Check if a service requires its dependencies to be healthy before starting.
267    ///
268    /// Returns the list of dependency service names that must reach "healthy" status.
269    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    /// Dependencies this service must wait to run to completion (exit 0) before
285    /// starting — `depends_on: { dep: { condition: service_completed_successfully } }`.
286    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    /// Get the health check config for a service, if defined.
302    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    /// Return true when a service explicitly disables its health check.
333    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/// Parsed health check specification (runtime-friendly).
355#[derive(Debug, Clone)]
356pub struct HealthCheckSpec {
357    /// Command to run.
358    pub cmd: Vec<String>,
359    /// Interval between checks in seconds.
360    pub interval_secs: u64,
361    /// Per-check timeout in seconds.
362    pub timeout_secs: u64,
363    /// Consecutive failures before unhealthy.
364    pub retries: u32,
365    /// Grace period before checks start counting.
366    pub start_period_secs: u64,
367}
368
369/// Parse a compose duration string (e.g., "30s", "1m", "500ms") into seconds.
370fn 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        // Assume seconds
385        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
452/// Parse a compose memory string (e.g., "512m", "1g", "1024") into MB.
453fn 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        // KB → MB (round up)
464        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        // Assume bytes
470        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        // db must come before api, api before web
530        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        // Shutdown is reverse of boot: web → api → db
582        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        // No default network → falls back to Tsi
827        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        // Service-level network takes precedence over default
886        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        // Simple depends_on → no health wait (condition defaults to service_started)
940        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        // `init` itself has no completion wait.
1092        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}