use std::collections::HashMap;
use std::path::Path;
use serde::Deserialize;
use crate::{Error, Result};
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ComposeFile {
#[serde(default)]
pub services: HashMap<String, Service>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Service {
#[serde(default)]
pub image: Option<String>,
#[serde(default)]
pub privileged: Option<bool>,
#[serde(default)]
pub network_mode: Option<String>,
#[serde(default)]
pub pid: Option<String>,
#[serde(default)]
pub userns_mode: Option<String>,
#[serde(default)]
pub cap_add: Option<Vec<String>>,
#[serde(default)]
pub security_opt: Option<Vec<String>>,
#[serde(default)]
pub volumes: Option<Vec<VolumeRef>>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum VolumeRef {
Short(String),
Long(LongVolume),
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct LongVolume {
#[serde(rename = "type", default)]
pub volume_type: Option<String>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub read_only: Option<bool>,
}
pub fn parse(path: &Path, body: &str) -> Result<ComposeFile> {
serde_yml::from_str(body).map_err(|e| Error::ComposeParse {
path: path.to_path_buf(),
reason: e.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
#[test]
fn parse_empty_yaml_yields_default() -> TestResult {
let f = parse(Path::new("compose.yml"), "version: '3'\n")?;
assert!(f.services.is_empty());
Ok(())
}
#[test]
fn parse_service_with_privileged_and_caps() -> TestResult {
let body = r#"
services:
app:
image: nginx
privileged: true
cap_add:
- SYS_ADMIN
network_mode: host
"#;
let f = parse(Path::new("compose.yml"), body)?;
let summary = f.services.get("app").map(|svc| {
(
svc.privileged,
svc.network_mode.clone(),
svc.cap_add.clone().unwrap_or_default(),
)
});
assert_eq!(
summary,
Some((Some(true), Some("host".into()), vec!["SYS_ADMIN".into()]))
);
Ok(())
}
#[test]
fn parse_short_and_long_volumes() -> TestResult {
let body = r#"
services:
app:
image: x
volumes:
- "/var/lib/docker:/host-docker:ro"
- type: bind
source: /etc
target: /host-etc
read_only: true
"#;
let f = parse(Path::new("compose.yml"), body)?;
let vols: Vec<_> = f
.services
.get("app")
.and_then(|s| s.volumes.as_ref())
.map(|v| {
v.iter()
.map(|vol| match vol {
VolumeRef::Short(s) => ("short", s.clone(), None, None, None),
VolumeRef::Long(l) => (
"long",
String::new(),
l.source.clone(),
l.target.clone(),
l.read_only,
),
})
.collect()
})
.unwrap_or_default();
assert_eq!(
vols,
vec![
(
"short",
"/var/lib/docker:/host-docker:ro".into(),
None,
None,
None,
),
(
"long",
String::new(),
Some("/etc".into()),
Some("/host-etc".into()),
Some(true),
),
]
);
Ok(())
}
#[test]
fn parse_invalid_yaml_errors_with_path() {
let result = parse(Path::new("compose.yml"), "::: invalid :::");
let parsed_path = match result {
Err(Error::ComposeParse { path, .. }) => Some(path.to_string_lossy().into_owned()),
_ => None,
};
assert_eq!(parsed_path, Some("compose.yml".into()));
}
}