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