mod common;
pub mod config;
pub mod duration;
mod include;
mod name;
pub mod network;
mod options;
pub mod secret;
mod serde;
pub mod service;
mod volume;
use std::{
collections::{hash_map::Entry, HashMap},
error::Error,
fmt::{self, Display, Formatter},
path::PathBuf,
};
use ::serde::{Deserialize, Serialize};
use indexmap::IndexMap;
pub use self::{
common::{
AsShort, AsShortIter, ExtensionKey, Extensions, Identifier, InvalidExtensionKeyError,
InvalidIdentifierError, InvalidMapKeyError, ItemOrList, ListOrMap, Map, MapKey, Number,
ParseNumberError, Resource, ShortOrLong, StringOrNumber, TryFromNumberError,
TryFromValueError, Value, YamlValue,
},
config::Config,
include::Include,
name::{InvalidNameError, Name},
network::Network,
options::Options,
secret::Secret,
service::Service,
volume::Volume,
};
pub type Networks = IndexMap<Identifier, Option<Resource<Network>>>;
pub type Volumes = IndexMap<Identifier, Option<Resource<Volume>>>;
pub type Configs = IndexMap<Identifier, Resource<Config>>;
pub type Secrets = IndexMap<Identifier, Resource<Secret>>;
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
pub struct Compose {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<Name>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<ShortOrLong<PathBuf, Include>>,
pub services: IndexMap<Identifier, Service>,
#[serde(default, skip_serializing_if = "Networks::is_empty")]
pub networks: Networks,
#[serde(default, skip_serializing_if = "Volumes::is_empty")]
pub volumes: Volumes,
#[serde(default, skip_serializing_if = "Configs::is_empty")]
pub configs: Configs,
#[serde(default, skip_serializing_if = "Secrets::is_empty")]
pub secrets: Secrets,
#[serde(flatten)]
pub extensions: Extensions,
}
impl Compose {
#[must_use]
pub fn options() -> Options {
Options::default()
}
pub fn validate_all(&self) -> Result<(), ValidationError> {
self.validate_networks()?;
self.validate_volumes()?;
self.validate_configs()?;
self.validate_secrets()?;
Ok(())
}
pub fn validate_networks(&self) -> Result<(), ValidationError> {
for (name, service) in &self.services {
service
.validate_networks(&self.networks)
.map_err(|resource| ValidationError {
service: Some(name.clone()),
resource,
kind: ResourceKind::Network,
})?;
}
Ok(())
}
pub fn validate_volumes(&self) -> Result<(), ValidationError> {
let volumes = self
.services
.values()
.flat_map(|service| service::volumes::named_volumes_iter(&service.volumes));
let mut seen_volumes = HashMap::new();
for volume in volumes {
match seen_volumes.entry(volume) {
Entry::Occupied(mut entry) => {
if !entry.get() && !self.volumes.contains_key(volume) {
return Err(ValidationError {
service: None,
resource: volume.clone(),
kind: ResourceKind::Volume,
});
}
*entry.get_mut() = true;
}
Entry::Vacant(entry) => {
entry.insert(false);
}
}
}
Ok(())
}
pub fn validate_configs(&self) -> Result<(), ValidationError> {
for (name, service) in &self.services {
service
.validate_configs(&self.configs)
.map_err(|resource| ValidationError {
service: Some(name.clone()),
resource,
kind: ResourceKind::Config,
})?;
}
Ok(())
}
pub fn validate_secrets(&self) -> Result<(), ValidationError> {
for (name, service) in &self.services {
service
.validate_secrets(&self.secrets)
.map_err(|resource| ValidationError {
service: Some(name.clone()),
resource,
kind: ResourceKind::Secret,
})?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationError {
service: Option<Identifier>,
resource: Identifier,
kind: ResourceKind,
}
impl Display for ValidationError {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
let Self {
service,
resource,
kind,
} = self;
write!(f, "{kind} `{resource}` ")?;
if let Some(service) = service {
write!(f, "(used in the `{service}` service) ")?;
}
if matches!(kind, ResourceKind::Volume) {
write!(f, "is used across multiple services and ")?;
}
write!(f, "is not defined in the top-level `{kind}s` field")
}
}
impl Error for ValidationError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResourceKind {
Network,
Volume,
Config,
Secret,
}
impl ResourceKind {
#[must_use]
const fn as_str(self) -> &'static str {
match self {
Self::Network => "network",
Self::Volume => "volume",
Self::Config => "config",
Self::Secret => "secret",
}
}
}
impl Display for ResourceKind {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
f.write_str(self.as_str())
}
}
macro_rules! impl_from {
($Ty:ident::$f:ident, $($From:ty),+ $(,)?) => {
$(
impl From<$From> for $Ty {
fn from(value: $From) -> Self {
Self::$f(value)
}
}
)+
};
}
use impl_from;
macro_rules! impl_try_from {
($Ty:ident::$f:ident -> $Error:ty, $($From:ty),+ $(,)?) => {
$(
impl TryFrom<$From> for $Ty {
type Error = $Error;
fn try_from(value: $From) -> Result<Self, Self::Error> {
Self::$f(value)
}
}
)+
};
}
use impl_try_from;
macro_rules! impl_from_str {
($($Ty:ident => $Error:ty),* $(,)?) => {
$(
impl ::std::str::FromStr for $Ty {
type Err = $Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
crate::impl_try_from! {
$Ty::parse -> $Error,
&str,
String,
Box<str>,
::std::borrow::Cow<'_, str>,
}
)*
};
($($Ty:ident),* $(,)?) => {
$(
impl ::std::str::FromStr for $Ty {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parse(s))
}
}
crate::impl_from!($Ty::parse, &str, String, Box<str>, ::std::borrow::Cow<'_, str>);
)*
};
}
use impl_from_str;
#[cfg(test)]
mod tests {
use indexmap::{indexmap, indexset};
use self::service::volumes::{ShortOptions, ShortVolume};
use super::*;
#[test]
fn full_round_trip() -> serde_yaml::Result<()> {
let yaml = include_str!("test-full.yaml");
let compose: Compose = serde_yaml::from_str(yaml)?;
assert_eq!(
serde_yaml::from_str::<serde_yaml::Value>(yaml)?,
serde_yaml::to_value(compose)?,
);
Ok(())
}
#[test]
fn validate_networks() -> Result<(), InvalidIdentifierError> {
let test = Identifier::new("test")?;
let network = Identifier::new("network")?;
let service = Service {
network_config: Some(service::NetworkConfig::Networks(
indexset![network.clone()].into(),
)),
..Service::default()
};
let mut compose = Compose {
services: indexmap! {
test.clone() => service,
},
..Compose::default()
};
assert_eq!(
compose.validate_networks(),
Err(ValidationError {
service: Some(test),
resource: network.clone(),
kind: ResourceKind::Network
})
);
compose.networks.insert(network, None);
assert_eq!(compose.validate_networks(), Ok(()));
Ok(())
}
#[test]
#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
fn validate_volumes() {
let volume_id = Identifier::new("volume").unwrap();
let volume = ShortVolume {
container_path: PathBuf::from("/container").try_into().unwrap(),
options: Some(ShortOptions::new(volume_id.clone().into())),
};
let service = Service {
volumes: indexset![volume.into()],
..Service::default()
};
let mut compose = Compose {
services: indexmap! {
Identifier::new("one").unwrap() => service.clone(),
},
..Compose::default()
};
assert_eq!(compose.validate_volumes(), Ok(()));
compose
.services
.insert(Identifier::new("two").unwrap(), service);
let error = Err(ValidationError {
service: None,
resource: volume_id.clone(),
kind: ResourceKind::Volume,
});
assert_eq!(compose.validate_volumes(), error);
let volume = compose.services[1].volumes.pop().unwrap();
compose.services[1]
.volumes
.insert(volume.into_long().into());
assert_eq!(compose.validate_volumes(), error);
compose.volumes.insert(volume_id, None);
assert_eq!(compose.validate_volumes(), Ok(()));
}
#[test]
fn validate_configs() -> Result<(), InvalidIdentifierError> {
let config = Identifier::new("config")?;
let service = Identifier::new("service")?;
let mut compose = Compose {
services: indexmap! {
service.clone() => Service {
configs: vec![config.clone().into()],
..Service::default()
},
},
..Compose::default()
};
assert_eq!(
compose.validate_configs(),
Err(ValidationError {
service: Some(service),
resource: config.clone(),
kind: ResourceKind::Config
})
);
compose
.configs
.insert(config, Resource::External { name: None });
assert_eq!(compose.validate_configs(), Ok(()));
Ok(())
}
#[test]
fn validate_secrets() -> Result<(), InvalidIdentifierError> {
let secret = Identifier::new("secret")?;
let service = Identifier::new("service")?;
let mut compose = Compose {
services: indexmap! {
service.clone() => Service {
secrets: vec![secret.clone().into()],
..Service::default()
},
},
..Compose::default()
};
assert_eq!(
compose.validate_secrets(),
Err(ValidationError {
service: Some(service),
resource: secret.clone(),
kind: ResourceKind::Secret
})
);
compose
.secrets
.insert(secret, Resource::External { name: None });
assert_eq!(compose.validate_secrets(), Ok(()));
Ok(())
}
}