Skip to main content

a3s_box_runtime/
compose.rs

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