compose_spec/lib.rs
1//! `compose_spec` is a library for (de)serializing from/to the [Compose specification].
2//!
3//! This library attempts to make interacting with and creating Compose files as idiomatic and
4//! correct as possible.
5//!
6//! - [`PathBuf`]s are used for fields which denote a path.
7//! - Enums are used for fields which conflict with each other.
8//! - Values are fully parsed and validated when they have a defined format.
9//! - Lists that must contain unique values use [`IndexSet`](indexmap::IndexSet), otherwise they are
10//! [`Vec`]s.
11//! - Strings which represent a span of time are converted to/from
12//! [`Duration`](std::time::Duration)s, see the [`duration`] module.
13//!
14//! Note that the [`Deserialize`] implementations of many types make use of
15//! [`Deserializer::deserialize_any()`](::serde::de::Deserializer::deserialize_any). This means that
16//! you should only attempt to deserialize them from self-describing formats like YAML or JSON.
17//!
18//! # Examples
19//!
20//! ```
21//! use compose_spec::{Compose, Service, service::Image};
22//!
23//! let yaml = "\
24//! services:
25//! caddy:
26//! image: docker.io/library/caddy:latest
27//! ports:
28//! - 8000:80
29//! - 8443:443
30//! volumes:
31//! - ./Caddyfile:/etc/caddy/Caddyfile
32//! - caddy-data:/data
33//! volumes:
34//! caddy-data:
35//! ";
36//!
37//! // Deserialize `Compose`
38//! let compose: Compose = serde_yaml::from_str(yaml)?;
39//!
40//! // Serialize `Compose`
41//! let value = serde_yaml::to_value(&compose)?;
42//! # let yaml: serde_yaml::Value = serde_yaml::from_str(yaml)?;
43//! # assert_eq!(value, yaml);
44//!
45//! // Get the `Image` of the "caddy" service
46//! let caddy: Option<&Service> = compose.services.get("caddy");
47//! let image: &Option<Image> = &caddy.unwrap().image;
48//! let image: &Image = image.as_ref().unwrap();
49//!
50//! assert_eq!(image, "docker.io/library/caddy:latest");
51//! assert_eq!(image.name(), "docker.io/library/caddy");
52//! assert_eq!(image.tag(), Some("latest"));
53//! # Ok::<(), serde_yaml::Error>(())
54//! ```
55//!
56//! # Short or Long Syntax Values
57//!
58//! Many values within the [Compose specification] can be represented in either a short or long
59//! syntax. The enum [`ShortOrLong`] is used to for these values. Conversion from the [`Short`]
60//! syntax to the [`Long`] syntax is always possible. The [`AsShort`] trait is used for [`Long`]
61//! syntax types which may be represented directly as the [`Short`] syntax type if additional
62//! options are not set.
63//!
64//! # [Fragments](https://github.com/compose-spec/compose-spec/blob/master/10-fragments.md)
65//!
66//! [`serde_yaml`] does use YAML anchors and aliases during deserialization. However, it does not
67//! automatically merge the [YAML merge type](https://yaml.org/type/merge.html) (`<<` keys). You
68//! can use [`serde_yaml::Value::apply_merge()`] to merge `<<` keys into the surrounding mapping.
69//! [`Options::apply_merge()`] is available to do this for you.
70//!
71//! ```
72//! use compose_spec::Compose;
73//!
74//! let yaml = "\
75//! services:
76//! one:
77//! environment: &env
78//! FOO: foo
79//! BAR: bar
80//! two:
81//! environment: *env
82//! three:
83//! environment:
84//! <<: *env
85//! BAR: baz
86//! ";
87//!
88//! let compose = Compose::options()
89//! .apply_merge(true)
90//! .from_yaml_str(yaml)?;
91//! # Ok::<(), serde_yaml::Error>(())
92//! ```
93//!
94//! [Compose specification]: https://github.com/compose-spec/compose-spec
95//! [`Short`]: ShortOrLong::Short
96//! [`Long`]: ShortOrLong::Long
97
98mod common;
99pub mod config;
100pub mod duration;
101mod include;
102mod name;
103pub mod network;
104mod options;
105pub mod secret;
106mod serde;
107pub mod service;
108mod volume;
109
110use std::{
111 collections::{hash_map::Entry, HashMap},
112 error::Error,
113 fmt::{self, Display, Formatter},
114 path::PathBuf,
115};
116
117use ::serde::{Deserialize, Serialize};
118use indexmap::IndexMap;
119
120pub use self::{
121 common::{
122 AsShort, AsShortIter, ExtensionKey, Extensions, Identifier, InvalidExtensionKeyError,
123 InvalidIdentifierError, InvalidMapKeyError, ItemOrList, ListOrMap, Map, MapKey, Number,
124 ParseNumberError, Resource, ShortOrLong, StringOrNumber, TryFromNumberError,
125 TryFromValueError, Value, YamlValue,
126 },
127 config::Config,
128 include::Include,
129 name::{InvalidNameError, Name},
130 network::Network,
131 options::Options,
132 secret::Secret,
133 service::Service,
134 volume::Volume,
135};
136
137/// Named networks which allow for [`Service`]s to communicate with each other.
138///
139/// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/06-networks.md)
140pub type Networks = IndexMap<Identifier, Option<Resource<Network>>>;
141
142/// Named volumes which can be reused across multiple [`Service`]s.
143///
144/// Volumes are persistent data stores implemented by the container engine.
145///
146/// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/07-volumes.md)
147pub type Volumes = IndexMap<Identifier, Option<Resource<Volume>>>;
148
149/// Configs allow [`Service`]s to adapt their behavior without needing to rebuild the container
150/// image.
151///
152/// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/08-configs.md)
153pub type Configs = IndexMap<Identifier, Resource<Config>>;
154
155/// Sensitive data that a [`Service`] may be granted access to.
156///
157/// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/09-secrets.md)
158pub type Secrets = IndexMap<Identifier, Resource<Secret>>;
159
160/// The Compose file is a YAML file defining a containers based application.
161///
162/// Note that the [`Deserialize`] implementations of many types within `Compose` make use of
163/// [`Deserializer::deserialize_any()`](::serde::de::Deserializer::deserialize_any). This means that
164/// you should only attempt to deserialize from self-describing formats like YAML or JSON.
165///
166/// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/03-compose-file.md)
167#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
168pub struct Compose {
169 /// Declared for backward compatibility, ignored.
170 ///
171 /// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/04-version-and-name.md#version-top-level-element)
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub version: Option<String>,
174
175 /// Define the Compose project name, until user defines one explicitly.
176 ///
177 /// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/04-version-and-name.md#name-top-level-element)
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub name: Option<Name>,
180
181 /// Compose sub-projects to be included.
182 ///
183 /// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/14-include.md)
184 #[serde(default, skip_serializing_if = "Vec::is_empty")]
185 pub include: Vec<ShortOrLong<PathBuf, Include>>,
186
187 /// The [`Service`]s (containerized computing components) of the application.
188 ///
189 /// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/05-services.md)
190 pub services: IndexMap<Identifier, Service>,
191
192 /// Named networks for [`Service`]s to communicate with each other.
193 ///
194 /// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/06-networks.md)
195 #[serde(default, skip_serializing_if = "Networks::is_empty")]
196 pub networks: Networks,
197
198 /// Named volumes which can be reused across multiple [`Service`]s.
199 ///
200 /// Volumes are persistent data stores implemented by the container engine.
201 ///
202 /// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/07-volumes.md)
203 #[serde(default, skip_serializing_if = "Volumes::is_empty")]
204 pub volumes: Volumes,
205
206 /// Configs allow [`Service`]s to adapt their behavior without needing to rebuild the container
207 /// image.
208 ///
209 /// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/08-configs.md)
210 #[serde(default, skip_serializing_if = "Configs::is_empty")]
211 pub configs: Configs,
212
213 /// Sensitive data that a [`Service`] may be granted access to.
214 ///
215 /// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/09-secrets.md)
216 #[serde(default, skip_serializing_if = "Secrets::is_empty")]
217 pub secrets: Secrets,
218
219 /// Extension values, which are (de)serialized via flattening.
220 ///
221 /// [compose-spec](https://github.com/compose-spec/compose-spec/blob/master/11-extension.md)
222 #[serde(flatten)]
223 pub extensions: Extensions,
224}
225
226impl Compose {
227 /// Builder for options to apply when deserializing a Compose file.
228 ///
229 /// Alias for [`Options::default()`].
230 ///
231 /// ```
232 /// use compose_spec::Compose;
233 ///
234 /// let yaml = "\
235 /// services:
236 /// hello:
237 /// image: quay.io/podman/hello:latest
238 /// ";
239 ///
240 /// let compose = Compose::options()
241 /// // ... add deserialization options
242 /// .from_yaml_str(yaml)?;
243 /// # Ok::<(), serde_yaml::Error>(())
244 /// ```
245 #[must_use]
246 pub fn options() -> Options {
247 Options::default()
248 }
249
250 /// Ensure that all [`Resource`]s ([`Network`]s, [`Volume`]s, [`Config`]s, and [`Secret`]s) used
251 /// in each [`Service`] are defined in the appropriate top-level field.
252 ///
253 /// Runs, in order, [`validate_networks()`](Self::validate_networks()),
254 /// [`validate_volumes()`](Self::validate_volumes()),
255 /// [`validate_configs()`](Self::validate_configs()), and
256 /// [`validate_secrets()`](Self::validate_secrets()).
257 ///
258 /// # Errors
259 ///
260 /// Returns the first error encountered, meaning an [`Identifier`] for a [`Resource`] was used
261 /// in a [`Service`] which is not defined in the appropriate top-level field.
262 pub fn validate_all(&self) -> Result<(), ValidationError> {
263 self.validate_networks()?;
264 self.validate_volumes()?;
265 self.validate_configs()?;
266 self.validate_secrets()?;
267 Ok(())
268 }
269
270 /// Ensure that the networks used in each [`Service`] are defined in the `networks` field.
271 ///
272 /// # Errors
273 ///
274 /// Returns an error if a [`Service`] uses an [`Identifier`] for a [`Network`] not defined in
275 /// the `networks` field.
276 ///
277 /// Only the first undefined network is listed in the error's [`Display`] output.
278 pub fn validate_networks(&self) -> Result<(), ValidationError> {
279 for (name, service) in &self.services {
280 service
281 .validate_networks(&self.networks)
282 .map_err(|resource| ValidationError {
283 service: Some(name.clone()),
284 resource,
285 kind: ResourceKind::Network,
286 })?;
287 }
288
289 Ok(())
290 }
291
292 /// Ensure that named volumes used across multiple [`Service`]s are defined in the `volumes`
293 /// field.
294 ///
295 /// # Errors
296 ///
297 /// Returns an error if a named volume [`Identifier`] is used across multiple [`Service`]s is
298 /// not defined in the `volumes` field.
299 ///
300 /// Only the first undefined named volume is listed in the error's [`Display`] output.
301 pub fn validate_volumes(&self) -> Result<(), ValidationError> {
302 let volumes = self
303 .services
304 .values()
305 .flat_map(|service| service::volumes::named_volumes_iter(&service.volumes));
306
307 let mut seen_volumes = HashMap::new();
308 for volume in volumes {
309 match seen_volumes.entry(volume) {
310 Entry::Occupied(mut entry) => {
311 if !entry.get() && !self.volumes.contains_key(volume) {
312 return Err(ValidationError {
313 service: None,
314 resource: volume.clone(),
315 kind: ResourceKind::Volume,
316 });
317 }
318 *entry.get_mut() = true;
319 }
320 Entry::Vacant(entry) => {
321 entry.insert(false);
322 }
323 }
324 }
325
326 Ok(())
327 }
328
329 /// Ensure that the configs used in each [`Service`] are defined in the `configs` field.
330 ///
331 /// # Errors
332 ///
333 /// Returns an error if a [`Service`] uses an [`Identifier`] for a [`Config`] not defined in
334 /// the `configs` field.
335 ///
336 /// Only the first undefined config is listed in the error's [`Display`] output.
337 pub fn validate_configs(&self) -> Result<(), ValidationError> {
338 for (name, service) in &self.services {
339 service
340 .validate_configs(&self.configs)
341 .map_err(|resource| ValidationError {
342 service: Some(name.clone()),
343 resource,
344 kind: ResourceKind::Config,
345 })?;
346 }
347
348 Ok(())
349 }
350
351 /// Ensure that the secrets used in each [`Service`] are defined in the `secrets` field.
352 ///
353 /// # Errors
354 ///
355 /// Returns an error if a [`Service`] uses an [`Identifier`] for a [`Secret`] not defined in
356 /// the `secrets` field.
357 ///
358 /// Only the first undefined secret is listed in the error's [`Display`] output.
359 pub fn validate_secrets(&self) -> Result<(), ValidationError> {
360 for (name, service) in &self.services {
361 service
362 .validate_secrets(&self.secrets)
363 .map_err(|resource| ValidationError {
364 service: Some(name.clone()),
365 resource,
366 kind: ResourceKind::Secret,
367 })?;
368 }
369
370 Ok(())
371 }
372}
373
374/// Error returned when validation of a [`Compose`] file fails.
375///
376/// Occurs when a [`Service`] uses a [`Resource`] which is not defined in the corresponding
377/// field in the [`Compose`].
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct ValidationError {
380 /// Name of the [`Service`] which uses the invalid `resource`.
381 service: Option<Identifier>,
382 /// Name of the resource which is not defined by the [`Compose`] file.
383 resource: Identifier,
384 /// The kind of the `resource`.
385 kind: ResourceKind,
386}
387
388impl Display for ValidationError {
389 fn fmt(&self, f: &mut Formatter) -> fmt::Result {
390 let Self {
391 service,
392 resource,
393 kind,
394 } = self;
395
396 write!(f, "{kind} `{resource}` ")?;
397
398 if let Some(service) = service {
399 write!(f, "(used in the `{service}` service) ")?;
400 }
401
402 if matches!(kind, ResourceKind::Volume) {
403 write!(f, "is used across multiple services and ")?;
404 }
405
406 write!(f, "is not defined in the top-level `{kind}s` field")
407 }
408}
409
410impl Error for ValidationError {}
411
412/// Kinds of [`Resource`]s that may be used in a [`ValidationError`].
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414enum ResourceKind {
415 /// [`Network`] resource kind.
416 Network,
417 /// [`Volume`] resource kind.
418 Volume,
419 /// [`Config`] resource kind.
420 Config,
421 /// [`Secret`] resource kind.
422 Secret,
423}
424
425impl ResourceKind {
426 /// Resource kind as a static string slice.
427 #[must_use]
428 const fn as_str(self) -> &'static str {
429 match self {
430 Self::Network => "network",
431 Self::Volume => "volume",
432 Self::Config => "config",
433 Self::Secret => "secret",
434 }
435 }
436}
437
438impl Display for ResourceKind {
439 fn fmt(&self, f: &mut Formatter) -> fmt::Result {
440 f.write_str(self.as_str())
441 }
442}
443
444/// Implement [`From`] for `Ty` using `f`.
445macro_rules! impl_from {
446 ($Ty:ident::$f:ident, $($From:ty),+ $(,)?) => {
447 $(
448 impl From<$From> for $Ty {
449 fn from(value: $From) -> Self {
450 Self::$f(value)
451 }
452 }
453 )+
454 };
455}
456
457use impl_from;
458
459/// Implement [`TryFrom`] for `Ty` using `f` which returns [`Result<Ty, Error>`].
460macro_rules! impl_try_from {
461 ($Ty:ident::$f:ident -> $Error:ty, $($From:ty),+ $(,)?) => {
462 $(
463 impl TryFrom<$From> for $Ty {
464 type Error = $Error;
465
466 fn try_from(value: $From) -> Result<Self, Self::Error> {
467 Self::$f(value)
468 }
469 }
470 )+
471 };
472}
473
474use impl_try_from;
475
476/// Implement string conversion traits for types which have a `parse` method.
477///
478/// For types with an error, the macro creates implementations of:
479///
480/// - [`FromStr`]
481/// - [`TryFrom<&str>`]
482/// - [`TryFrom<String>`]
483/// - [`TryFrom<Box<str>>`]
484/// - [`TryFrom<Cow<str>>`]
485///
486/// For types without an error, the macro creates implementations of:
487///
488/// - [`FromStr`], where `Err` is [`Infallible`](std::convert::Infallible)
489/// - [`From<&str>`]
490/// - [`From<String>`]
491/// - [`From<Box<str>>`]
492/// - [`From<Cow<str>>`]
493///
494/// [`FromStr`]: std::str::FromStr
495macro_rules! impl_from_str {
496 ($($Ty:ident => $Error:ty),* $(,)?) => {
497 $(
498 impl ::std::str::FromStr for $Ty {
499 type Err = $Error;
500
501 fn from_str(s: &str) -> Result<Self, Self::Err> {
502 Self::parse(s)
503 }
504 }
505
506 crate::impl_try_from! {
507 $Ty::parse -> $Error,
508 &str,
509 String,
510 Box<str>,
511 ::std::borrow::Cow<'_, str>,
512 }
513 )*
514 };
515 ($($Ty:ident),* $(,)?) => {
516 $(
517 impl ::std::str::FromStr for $Ty {
518 type Err = std::convert::Infallible;
519
520 fn from_str(s: &str) -> Result<Self, Self::Err> {
521 Ok(Self::parse(s))
522 }
523 }
524
525 crate::impl_from!($Ty::parse, &str, String, Box<str>, ::std::borrow::Cow<'_, str>);
526 )*
527 };
528}
529
530use impl_from_str;
531
532#[cfg(test)]
533mod tests {
534 use indexmap::{indexmap, indexset};
535
536 use self::service::volumes::{ShortOptions, ShortVolume};
537
538 use super::*;
539
540 #[test]
541 fn full_round_trip() -> serde_yaml::Result<()> {
542 let yaml = include_str!("test-full.yaml");
543
544 let compose: Compose = serde_yaml::from_str(yaml)?;
545
546 assert_eq!(
547 serde_yaml::from_str::<serde_yaml::Value>(yaml)?,
548 serde_yaml::to_value(compose)?,
549 );
550
551 Ok(())
552 }
553
554 #[test]
555 fn validate_networks() -> Result<(), InvalidIdentifierError> {
556 let test = Identifier::new("test")?;
557 let network = Identifier::new("network")?;
558
559 let service = Service {
560 network_config: Some(service::NetworkConfig::Networks(
561 indexset![network.clone()].into(),
562 )),
563 ..Service::default()
564 };
565
566 let mut compose = Compose {
567 services: indexmap! {
568 test.clone() => service,
569 },
570 ..Compose::default()
571 };
572 assert_eq!(
573 compose.validate_networks(),
574 Err(ValidationError {
575 service: Some(test),
576 resource: network.clone(),
577 kind: ResourceKind::Network
578 })
579 );
580
581 compose.networks.insert(network, None);
582 assert_eq!(compose.validate_networks(), Ok(()));
583
584 Ok(())
585 }
586
587 #[test]
588 #[allow(clippy::unwrap_used, clippy::indexing_slicing)]
589 fn validate_volumes() {
590 let volume_id = Identifier::new("volume").unwrap();
591 let volume = ShortVolume {
592 container_path: PathBuf::from("/container").try_into().unwrap(),
593 options: Some(ShortOptions::new(volume_id.clone().into())),
594 };
595 let service = Service {
596 volumes: indexset![volume.into()],
597 ..Service::default()
598 };
599
600 let mut compose = Compose {
601 services: indexmap! {
602 Identifier::new("one").unwrap() => service.clone(),
603 },
604 ..Compose::default()
605 };
606
607 assert_eq!(compose.validate_volumes(), Ok(()));
608
609 compose
610 .services
611 .insert(Identifier::new("two").unwrap(), service);
612 let error = Err(ValidationError {
613 service: None,
614 resource: volume_id.clone(),
615 kind: ResourceKind::Volume,
616 });
617 assert_eq!(compose.validate_volumes(), error);
618
619 let volume = compose.services[1].volumes.pop().unwrap();
620 compose.services[1]
621 .volumes
622 .insert(volume.into_long().into());
623 assert_eq!(compose.validate_volumes(), error);
624
625 compose.volumes.insert(volume_id, None);
626 assert_eq!(compose.validate_volumes(), Ok(()));
627 }
628
629 #[test]
630 fn validate_configs() -> Result<(), InvalidIdentifierError> {
631 let config = Identifier::new("config")?;
632 let service = Identifier::new("service")?;
633
634 let mut compose = Compose {
635 services: indexmap! {
636 service.clone() => Service {
637 configs: vec![config.clone().into()],
638 ..Service::default()
639 },
640 },
641 ..Compose::default()
642 };
643 assert_eq!(
644 compose.validate_configs(),
645 Err(ValidationError {
646 service: Some(service),
647 resource: config.clone(),
648 kind: ResourceKind::Config
649 })
650 );
651
652 compose
653 .configs
654 .insert(config, Resource::External { name: None });
655 assert_eq!(compose.validate_configs(), Ok(()));
656
657 Ok(())
658 }
659
660 #[test]
661 fn validate_secrets() -> Result<(), InvalidIdentifierError> {
662 let secret = Identifier::new("secret")?;
663 let service = Identifier::new("service")?;
664
665 let mut compose = Compose {
666 services: indexmap! {
667 service.clone() => Service {
668 secrets: vec![secret.clone().into()],
669 ..Service::default()
670 },
671 },
672 ..Compose::default()
673 };
674 assert_eq!(
675 compose.validate_secrets(),
676 Err(ValidationError {
677 service: Some(service),
678 resource: secret.clone(),
679 kind: ResourceKind::Secret
680 })
681 );
682
683 compose
684 .secrets
685 .insert(secret, Resource::External { name: None });
686 assert_eq!(compose.validate_secrets(), Ok(()));
687
688 Ok(())
689 }
690}