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