1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub struct NormalizedComposeConfig {
20 pub services: BTreeMap<String, NormalizedServiceConfig>,
22 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
24 pub volumes: BTreeMap<String, NormalizedVolumeDeclaration>,
25 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
27 pub networks: BTreeMap<String, NormalizedNetworkDeclaration>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct NormalizedServiceConfig {
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub image: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub entrypoint: Option<Vec<String>>,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub command: Option<Vec<String>>,
42 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
44 pub environment: BTreeMap<String, String>,
45 #[serde(skip_serializing_if = "Vec::is_empty")]
47 pub env_file: Vec<String>,
48 #[serde(skip_serializing_if = "Vec::is_empty")]
50 pub ports: Vec<String>,
51 #[serde(skip_serializing_if = "Vec::is_empty")]
53 pub volumes: Vec<String>,
54 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
56 pub depends_on: BTreeMap<String, NormalizedDependsOn>,
57 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
59 pub networks: BTreeMap<String, NormalizedServiceNetwork>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub cpus: Option<u32>,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub mem_limit: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub restart: Option<String>,
69 #[serde(skip_serializing_if = "Vec::is_empty")]
71 pub dns: Vec<String>,
72 #[serde(skip_serializing_if = "Vec::is_empty")]
74 pub tmpfs: Vec<String>,
75 #[serde(skip_serializing_if = "Vec::is_empty")]
77 pub cap_add: Vec<String>,
78 #[serde(skip_serializing_if = "Vec::is_empty")]
80 pub cap_drop: Vec<String>,
81 #[serde(skip_serializing_if = "is_false")]
83 pub privileged: bool,
84 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
86 pub labels: BTreeMap<String, String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub healthcheck: Option<NormalizedHealthcheckConfig>,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub working_dir: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub hostname: Option<String>,
96 #[serde(skip_serializing_if = "Vec::is_empty")]
98 pub extra_hosts: Vec<String>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103pub struct NormalizedDependsOn {
104 pub condition: String,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
110pub struct NormalizedServiceNetwork {
111 #[serde(skip_serializing_if = "Vec::is_empty")]
113 pub aliases: Vec<String>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
118pub struct NormalizedHealthcheckConfig {
119 #[serde(skip_serializing_if = "Vec::is_empty")]
121 pub test: Vec<String>,
122 #[serde(skip_serializing_if = "is_false")]
124 pub disable: bool,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub interval: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub timeout: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub retries: Option<u32>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub start_period: Option<String>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
141pub struct NormalizedVolumeDeclaration {
142 pub driver: String,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
148pub struct NormalizedNetworkDeclaration {
149 pub driver: String,
151}
152
153fn is_false(value: &bool) -> bool {
154 !*value
155}
156
157impl NormalizedComposeConfig {
158 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 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 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}