Skip to main content

a3s_box_core/compose/
normalized.rs

1//! Canonical Compose data model and compatibility conversion.
2
3use std::collections::BTreeMap;
4
5use serde::Serialize;
6
7use super::diagnostic::{pointer_segment, ComposeDiagnostic};
8use super::{
9    ComposeConfig, ComposeDiagnosticCode, ComposeNormalizationError, DependsOn, DependsOnCondition,
10    DnsConfig, EnvVars, HealthcheckConfig, Labels, NetworkDeclaration, ServiceConfig,
11    ServiceNetworkConfig, ServiceNetworks, StringOrList, VolumeDeclaration,
12};
13
14/// Deterministic, syntax-independent Compose project model.
15///
16/// All semantic maps use `BTreeMap`, alternate list/map spellings are collapsed
17/// into one representation, and supported driver defaults are explicit.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub struct NormalizedComposeConfig {
20    /// Canonically ordered service definitions.
21    pub services: BTreeMap<String, NormalizedServiceConfig>,
22    /// Canonically ordered named volume declarations.
23    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
24    pub volumes: BTreeMap<String, NormalizedVolumeDeclaration>,
25    /// Canonically ordered named network declarations.
26    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
27    pub networks: BTreeMap<String, NormalizedNetworkDeclaration>,
28}
29
30/// Canonical service definition independent of ACL/YAML spelling.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct NormalizedServiceConfig {
33    /// OCI image reference.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub image: Option<String>,
36    /// Tokenized entrypoint override.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub entrypoint: Option<Vec<String>>,
39    /// Tokenized command override.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub command: Option<Vec<String>>,
42    /// Canonically ordered inline environment.
43    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
44    pub environment: BTreeMap<String, String>,
45    /// Environment files in precedence order.
46    #[serde(skip_serializing_if = "Vec::is_empty")]
47    pub env_file: Vec<String>,
48    /// Canonically ordered transient environment references. Keys are guest
49    /// variables and values are caller process-environment variable names.
50    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
51    pub secret_environment: BTreeMap<String, String>,
52    /// Validated and normalized TCP port mappings.
53    #[serde(skip_serializing_if = "Vec::is_empty")]
54    pub ports: Vec<String>,
55    /// Volume mounts in source order.
56    #[serde(skip_serializing_if = "Vec::is_empty")]
57    pub volumes: Vec<String>,
58    /// Canonically ordered dependency conditions.
59    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
60    pub depends_on: BTreeMap<String, NormalizedDependsOn>,
61    /// Canonically ordered service networks.
62    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
63    pub networks: BTreeMap<String, NormalizedServiceNetwork>,
64    /// Requested virtual CPU count.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub cpus: Option<u32>,
67    /// Compose memory limit.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub mem_limit: Option<String>,
70    /// Compose restart policy.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub restart: Option<String>,
73    /// DNS servers in source order.
74    #[serde(skip_serializing_if = "Vec::is_empty")]
75    pub dns: Vec<String>,
76    /// tmpfs mounts in source order.
77    #[serde(skip_serializing_if = "Vec::is_empty")]
78    pub tmpfs: Vec<String>,
79    /// Linux capabilities to add.
80    #[serde(skip_serializing_if = "Vec::is_empty")]
81    pub cap_add: Vec<String>,
82    /// Linux capabilities to drop.
83    #[serde(skip_serializing_if = "Vec::is_empty")]
84    pub cap_drop: Vec<String>,
85    /// Whether privileged execution is requested.
86    #[serde(skip_serializing_if = "is_false")]
87    pub privileged: bool,
88    /// Canonically ordered service labels.
89    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
90    pub labels: BTreeMap<String, String>,
91    /// Optional service health check.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub healthcheck: Option<NormalizedHealthcheckConfig>,
94    /// Working directory inside the workload.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub working_dir: Option<String>,
97    /// Workload hostname.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub hostname: Option<String>,
100    /// Static host entries in source order.
101    #[serde(skip_serializing_if = "Vec::is_empty")]
102    pub extra_hosts: Vec<String>,
103}
104
105/// Canonical dependency condition.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107pub struct NormalizedDependsOn {
108    /// Validated dependency condition.
109    pub condition: String,
110}
111
112/// Canonical per-service network settings.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114pub struct NormalizedServiceNetwork {
115    /// Validated, sorted, deduplicated DNS aliases.
116    #[serde(skip_serializing_if = "Vec::is_empty")]
117    pub aliases: Vec<String>,
118}
119
120/// Canonical health-check settings.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
122pub struct NormalizedHealthcheckConfig {
123    /// Tokenized health-check command.
124    #[serde(skip_serializing_if = "Vec::is_empty")]
125    pub test: Vec<String>,
126    /// Whether the health check is explicitly disabled.
127    #[serde(skip_serializing_if = "is_false")]
128    pub disable: bool,
129    /// Interval between health checks.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub interval: Option<String>,
132    /// Timeout for one health check.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub timeout: Option<String>,
135    /// Consecutive failures before unhealthy.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub retries: Option<u32>,
138    /// Startup grace period.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub start_period: Option<String>,
141}
142
143/// Canonical named-volume declaration.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
145pub struct NormalizedVolumeDeclaration {
146    /// Validated volume driver (`local`).
147    pub driver: String,
148}
149
150/// Canonical named-network declaration.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
152pub struct NormalizedNetworkDeclaration {
153    /// Validated network driver (`bridge`).
154    pub driver: String,
155}
156
157fn is_false(value: &bool) -> bool {
158    !*value
159}
160
161impl NormalizedComposeConfig {
162    /// Serialize a byte-stable, pretty JSON representation with a final newline.
163    pub fn to_canonical_json(&self) -> Result<String, serde_json::Error> {
164        serde_json::to_string_pretty(self).map(|json| format!("{json}\n"))
165    }
166
167    /// Compute a deterministic dependency-first service order.
168    pub fn service_order(&self) -> Result<Vec<String>, ComposeNormalizationError> {
169        let mut diagnostics = Vec::new();
170        for (service_name, service) in &self.services {
171            for dependency in service.depends_on.keys() {
172                if !self.services.contains_key(dependency) {
173                    diagnostics.push(ComposeDiagnostic::new(
174                        ComposeDiagnosticCode::InvalidValue,
175                        format!(
176                            "/services/{}/depends_on/{}",
177                            pointer_segment(service_name),
178                            pointer_segment(dependency)
179                        ),
180                        format!(
181                            "service {service_name:?} depends on undefined service {dependency:?}"
182                        ),
183                    ));
184                }
185            }
186        }
187        if !diagnostics.is_empty() {
188            return Err(ComposeNormalizationError::new(diagnostics));
189        }
190
191        let mut state = BTreeMap::<String, u8>::new();
192        let mut order = Vec::new();
193        for service_name in self.services.keys() {
194            visit_service(self, service_name, &mut state, &mut order)?;
195        }
196        Ok(order)
197    }
198
199    /// Convert the canonical model into the compatibility model consumed by
200    /// the current Runtime translation layer.
201    pub fn into_config(self) -> ComposeConfig {
202        self.into()
203    }
204}
205
206fn visit_service(
207    config: &NormalizedComposeConfig,
208    service_name: &str,
209    state: &mut BTreeMap<String, u8>,
210    order: &mut Vec<String>,
211) -> Result<(), ComposeNormalizationError> {
212    match state.get(service_name) {
213        Some(1) => {
214            return Err(ComposeNormalizationError::one(ComposeDiagnostic::new(
215                ComposeDiagnosticCode::InvalidValue,
216                format!("/services/{}/depends_on", pointer_segment(service_name)),
217                format!("dependency cycle detected involving service {service_name:?}"),
218            )));
219        }
220        Some(2) => return Ok(()),
221        _ => {}
222    }
223    state.insert(service_name.to_string(), 1);
224    if let Some(service) = config.services.get(service_name) {
225        for dependency in service.depends_on.keys() {
226            visit_service(config, dependency, state, order)?;
227        }
228    }
229    state.insert(service_name.to_string(), 2);
230    order.push(service_name.to_string());
231    Ok(())
232}
233
234impl From<NormalizedComposeConfig> for ComposeConfig {
235    fn from(config: NormalizedComposeConfig) -> Self {
236        Self {
237            version: None,
238            services: config
239                .services
240                .into_iter()
241                .map(|(name, service)| (name, service.into()))
242                .collect(),
243            volumes: config
244                .volumes
245                .into_iter()
246                .map(|(name, declaration)| {
247                    (
248                        name,
249                        Some(VolumeDeclaration {
250                            driver: Some(declaration.driver),
251                        }),
252                    )
253                })
254                .collect(),
255            networks: config
256                .networks
257                .into_iter()
258                .map(|(name, declaration)| {
259                    (
260                        name,
261                        Some(NetworkDeclaration {
262                            driver: Some(declaration.driver),
263                        }),
264                    )
265                })
266                .collect(),
267        }
268    }
269}
270
271impl From<NormalizedServiceConfig> for ServiceConfig {
272    fn from(service: NormalizedServiceConfig) -> Self {
273        Self {
274            image: service.image,
275            entrypoint: service.entrypoint.map(StringOrList::List),
276            command: service.command.map(StringOrList::List),
277            environment: if service.environment.is_empty() {
278                EnvVars::Empty
279            } else {
280                EnvVars::Map(service.environment.into_iter().collect())
281            },
282            env_file: list_or_empty(service.env_file),
283            secret_environment: service.secret_environment.into_iter().collect(),
284            ports: service.ports,
285            volumes: service.volumes,
286            depends_on: if service.depends_on.is_empty() {
287                DependsOn::Empty
288            } else {
289                DependsOn::Map(
290                    service
291                        .depends_on
292                        .into_iter()
293                        .map(|(name, dependency)| {
294                            (
295                                name,
296                                DependsOnCondition {
297                                    condition: dependency.condition,
298                                },
299                            )
300                        })
301                        .collect(),
302                )
303            },
304            networks: if service.networks.is_empty() {
305                ServiceNetworks::Empty
306            } else {
307                ServiceNetworks::Map(
308                    service
309                        .networks
310                        .into_iter()
311                        .map(|(name, network)| {
312                            (
313                                name,
314                                Some(ServiceNetworkConfig {
315                                    aliases: network.aliases,
316                                }),
317                            )
318                        })
319                        .collect(),
320                )
321            },
322            cpus: service.cpus,
323            mem_limit: service.mem_limit,
324            restart: service.restart,
325            dns: if service.dns.is_empty() {
326                DnsConfig::Empty
327            } else {
328                DnsConfig::List(service.dns)
329            },
330            tmpfs: list_or_empty(service.tmpfs),
331            cap_add: service.cap_add,
332            cap_drop: service.cap_drop,
333            privileged: service.privileged,
334            labels: if service.labels.is_empty() {
335                Labels::Empty
336            } else {
337                Labels::Map(service.labels.into_iter().collect())
338            },
339            healthcheck: service.healthcheck.map(|healthcheck| HealthcheckConfig {
340                test: list_or_empty(healthcheck.test),
341                disable: healthcheck.disable,
342                interval: healthcheck.interval,
343                timeout: healthcheck.timeout,
344                retries: healthcheck.retries,
345                start_period: healthcheck.start_period,
346            }),
347            working_dir: service.working_dir,
348            hostname: service.hostname,
349            extra_hosts: list_or_empty(service.extra_hosts),
350        }
351    }
352}
353
354fn list_or_empty(values: Vec<String>) -> StringOrList {
355    if values.is_empty() {
356        StringOrList::Empty
357    } else {
358        StringOrList::List(values)
359    }
360}