use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::Labels;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum VolumeType {
Volume,
Bind,
Tmpfs,
Npipe,
Cluster,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct BindOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub propagation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub create_host_path: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub selinux: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct VolumeOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub nocopy: Option<bool>,
#[serde(default)]
pub labels: Labels,
#[serde(skip_serializing_if = "Option::is_none")]
pub driver_config: Option<DriverConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subpath: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct DriverConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub options: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TmpfsOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(
default,
deserialize_with = "deserialize_octal_mode",
skip_serializing_if = "Option::is_none"
)]
pub mode: Option<u32>,
}
fn deserialize_octal_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Int(u32),
Str(String),
}
match Option::<Raw>::deserialize(deserializer)? {
None => Ok(None),
Some(Raw::Int(n)) => Ok(Some(int_mode_bits(n))),
Some(Raw::Str(s)) => {
let trimmed = s.trim();
let digits = trimmed
.strip_prefix("0o")
.or_else(|| trimmed.strip_prefix("0O"))
.unwrap_or(trimmed);
u32::from_str_radix(digits, 8).map(Some).map_err(|_| {
D::Error::custom(format!(
"invalid tmpfs mode {s:?}: use octal notation like 0700 or 0o700"
))
})
}
}
}
fn int_mode_bits(n: u32) -> u32 {
u32::from_str_radix(&n.to_string(), 8).unwrap_or(n)
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
pub enum VolumeMount {
Short(String),
Long {
#[serde(rename = "type")]
volume_type: VolumeType,
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<String>,
target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
read_only: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bind: Option<BindOptions>,
#[serde(default, skip_serializing_if = "Option::is_none")]
volume: Option<VolumeOptions>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tmpfs: Option<TmpfsOptions>,
#[serde(default, skip_serializing_if = "Option::is_none")]
consistency: Option<String>,
},
}
impl VolumeMount {
pub fn target(&self) -> &str {
match self {
VolumeMount::Short(s) => {
let parts: Vec<&str> = s.splitn(3, ':').collect();
if parts.len() >= 2 {
parts[1]
} else {
parts[0]
}
}
VolumeMount::Long { target, .. } => target,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[non_exhaustive]
pub struct VolumeConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub driver: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub driver_opts: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub labels: Labels,
#[serde(flatten, default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
pub unknown: indexmap::IndexMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ServiceConfigRef {
Short(String),
Long {
source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
uid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
gid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mode: Option<u32>,
},
}
impl ServiceConfigRef {
pub fn source(&self) -> &str {
match self {
ServiceConfigRef::Short(s) => s,
ServiceConfigRef::Long { source, .. } => source,
}
}
pub fn target(&self) -> Option<&str> {
match self {
ServiceConfigRef::Short(_) => None,
ServiceConfigRef::Long { target, .. } => target.as_deref(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ServiceSecretRef {
Short(String),
Long {
source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
uid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
gid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mode: Option<u32>,
},
}
impl ServiceSecretRef {
pub fn source(&self) -> &str {
match self {
ServiceSecretRef::Short(s) => s,
ServiceSecretRef::Long { source, .. } => source,
}
}
pub fn target(&self) -> Option<&str> {
match self {
ServiceSecretRef::Short(_) => None,
ServiceSecretRef::Long { target, .. } => target.as_deref(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tmpfs_mode_octal_string_is_parsed_as_octal() {
let opts: TmpfsOptions = serde_yaml::from_str("mode: \"0700\"\n").unwrap();
assert_eq!(opts.mode, Some(0o700));
let opts: TmpfsOptions = serde_yaml::from_str("mode: \"0o755\"\n").unwrap();
assert_eq!(opts.mode, Some(0o755));
}
#[test]
fn tmpfs_mode_octal_yaml_literal_is_preserved_as_bits() {
let opts: TmpfsOptions = serde_yaml::from_str("mode: 0o700\n").unwrap();
assert_eq!(opts.mode, Some(0o700));
}
#[test]
fn tmpfs_mode_invalid_octal_is_clear_error() {
let err = serde_yaml::from_str::<TmpfsOptions>("mode: \"0o9\"\n").unwrap_err();
assert!(err.to_string().contains("tmpfs mode"), "got: {err}");
}
#[test]
fn tmpfs_mode_bare_decimal_is_interpreted_as_octal() {
let opts: TmpfsOptions = serde_yaml::from_str("mode: 700\n").unwrap();
assert_eq!(opts.mode, Some(0o700));
let opts: TmpfsOptions = serde_yaml::from_str("mode: 644\n").unwrap();
assert_eq!(opts.mode, Some(0o644));
}
#[test]
fn int_mode_bits_treats_decoded_0o_literals_as_bits() {
assert_eq!(int_mode_bits(700), 0o700);
assert_eq!(int_mode_bits(0o755), 0o755);
assert_eq!(int_mode_bits(0o700), 0o700);
}
#[test]
fn volume_mount_short_two_parts_returns_second() {
let m = VolumeMount::Short("./data:/app/data".to_string());
assert_eq!(m.target(), "/app/data");
}
#[test]
fn volume_mount_short_three_parts_returns_second() {
let m = VolumeMount::Short("./data:/app/data:ro".to_string());
assert_eq!(m.target(), "/app/data");
}
#[test]
fn volume_mount_short_no_colon_returns_whole_string() {
let m = VolumeMount::Short("/app/data".to_string());
assert_eq!(m.target(), "/app/data");
}
#[test]
fn volume_mount_long_returns_target_field() {
let m = VolumeMount::Long {
volume_type: VolumeType::Bind,
source: Some("/host/path".to_string()),
target: "/container/path".to_string(),
read_only: None,
bind: None,
volume: None,
tmpfs: None,
consistency: None,
};
assert_eq!(m.target(), "/container/path");
}
#[test]
fn config_ref_short_source() {
let r = ServiceConfigRef::Short("my-config".to_string());
assert_eq!(r.source(), "my-config");
assert!(r.target().is_none());
}
#[test]
fn config_ref_long_source_and_target() {
let r = ServiceConfigRef::Long {
source: "my-config".to_string(),
target: Some("/run/configs/my-config".to_string()),
uid: None,
gid: None,
mode: None,
};
assert_eq!(r.source(), "my-config");
assert_eq!(r.target(), Some("/run/configs/my-config"));
}
#[test]
fn config_ref_long_no_target() {
let r = ServiceConfigRef::Long {
source: "my-config".to_string(),
target: None,
uid: None,
gid: None,
mode: None,
};
assert!(r.target().is_none());
}
#[test]
fn secret_ref_short_source() {
let r = ServiceSecretRef::Short("my-secret".to_string());
assert_eq!(r.source(), "my-secret");
assert!(r.target().is_none());
}
#[test]
fn secret_ref_long_source_and_target() {
let r = ServiceSecretRef::Long {
source: "my-secret".to_string(),
target: Some("/run/secrets/my-secret".to_string()),
uid: None,
gid: None,
mode: None,
};
assert_eq!(r.source(), "my-secret");
assert_eq!(r.target(), Some("/run/secrets/my-secret"));
}
#[test]
fn secret_ref_long_no_target() {
let r = ServiceSecretRef::Long {
source: "my-secret".to_string(),
target: None,
uid: None,
gid: None,
mode: None,
};
assert_eq!(r.source(), "my-secret");
assert!(r.target().is_none());
}
}