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