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}
OSZAR »