1use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11use super::Labels;
12
13#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
15#[serde(rename_all = "lowercase")]
16pub enum VolumeType {
17 Volume,
19 Bind,
21 Tmpfs,
23 Npipe,
25 Cluster,
27}
28
29#[derive(Debug, Clone, Deserialize, Serialize, Default)]
31pub struct BindOptions {
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub propagation: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub create_host_path: Option<bool>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub selinux: Option<String>,
41}
42
43#[derive(Debug, Clone, Deserialize, Serialize, Default)]
45pub struct VolumeOptions {
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub nocopy: Option<bool>,
49 #[serde(default)]
51 pub labels: Labels,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub driver_config: Option<DriverConfig>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub subpath: Option<String>,
58}
59
60#[derive(Debug, Clone, Deserialize, Serialize, Default)]
62pub struct DriverConfig {
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub name: Option<String>,
66 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
68 pub options: HashMap<String, String>,
69}
70
71#[derive(Debug, Clone, Deserialize, Serialize, Default)]
73pub struct TmpfsOptions {
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub size: Option<u64>,
77 #[serde(
84 default,
85 deserialize_with = "deserialize_octal_mode",
86 skip_serializing_if = "Option::is_none"
87 )]
88 pub mode: Option<u32>,
89}
90
91fn deserialize_octal_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
102where
103 D: serde::Deserializer<'de>,
104{
105 use serde::de::Error;
106
107 #[derive(Deserialize)]
108 #[serde(untagged)]
109 enum Raw {
110 Int(u32),
111 Str(String),
112 }
113
114 match Option::<Raw>::deserialize(deserializer)? {
115 None => Ok(None),
116 Some(Raw::Int(n)) => Ok(Some(int_mode_bits(n))),
117 Some(Raw::Str(s)) => {
118 let trimmed = s.trim();
119 let digits = trimmed
120 .strip_prefix("0o")
121 .or_else(|| trimmed.strip_prefix("0O"))
122 .unwrap_or(trimmed);
123 u32::from_str_radix(digits, 8).map(Some).map_err(|_| {
124 D::Error::custom(format!(
125 "invalid mode {s:?}: use octal notation like 0700 or 0o700"
126 ))
127 })
128 }
129 }
130}
131
132fn int_mode_bits(n: u32) -> u32 {
141 u32::from_str_radix(&n.to_string(), 8).unwrap_or(n)
142}
143
144#[derive(Debug, Clone, Deserialize, Serialize)]
146#[serde(untagged)]
147#[allow(clippy::large_enum_variant)]
148pub enum VolumeMount {
149 Short(String),
151 Long {
153 #[serde(rename = "type")]
155 volume_type: VolumeType,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 source: Option<String>,
159 target: String,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 read_only: Option<bool>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 bind: Option<BindOptions>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 volume: Option<VolumeOptions>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 tmpfs: Option<TmpfsOptions>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 consistency: Option<String>,
176 },
177}
178
179impl VolumeMount {
180 pub fn target(&self) -> &str {
182 match self {
183 VolumeMount::Short(s) => {
184 let parts: Vec<&str> = s.splitn(3, ':').collect();
185 if parts.len() >= 2 {
186 parts[1]
187 } else {
188 parts[0]
189 }
190 }
191 VolumeMount::Long { target, .. } => target,
192 }
193 }
194}
195
196#[derive(Debug, Clone, Deserialize, Serialize, Default)]
198#[non_exhaustive]
199pub struct VolumeConfig {
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub driver: Option<String>,
203 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
205 pub driver_opts: HashMap<String, String>,
206 #[serde(skip_serializing_if = "Option::is_none")]
208 pub external: Option<bool>,
209 #[serde(skip_serializing_if = "Option::is_none")]
211 pub name: Option<String>,
212 #[serde(default)]
214 pub labels: Labels,
215 #[serde(flatten, default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
217 pub unknown: indexmap::IndexMap<String, serde_yaml::Value>,
218}
219
220#[derive(Debug, Clone, Deserialize, Serialize)]
222#[serde(untagged)]
223pub enum ServiceConfigRef {
224 Short(String),
226 Long {
228 source: String,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 target: Option<String>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 uid: Option<String>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 gid: Option<String>,
239 #[serde(
243 default,
244 deserialize_with = "deserialize_octal_mode",
245 skip_serializing_if = "Option::is_none"
246 )]
247 mode: Option<u32>,
248 },
249}
250
251impl ServiceConfigRef {
252 pub fn source(&self) -> &str {
254 match self {
255 ServiceConfigRef::Short(s) => s,
256 ServiceConfigRef::Long { source, .. } => source,
257 }
258 }
259
260 pub fn target(&self) -> Option<&str> {
262 match self {
263 ServiceConfigRef::Short(_) => None,
264 ServiceConfigRef::Long { target, .. } => target.as_deref(),
265 }
266 }
267}
268
269#[derive(Debug, Clone, Deserialize, Serialize)]
271#[serde(untagged)]
272pub enum ServiceSecretRef {
273 Short(String),
275 Long {
277 source: String,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 target: Option<String>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 uid: Option<String>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
287 gid: Option<String>,
288 #[serde(
292 default,
293 deserialize_with = "deserialize_octal_mode",
294 skip_serializing_if = "Option::is_none"
295 )]
296 mode: Option<u32>,
297 },
298}
299
300impl ServiceSecretRef {
301 pub fn source(&self) -> &str {
303 match self {
304 ServiceSecretRef::Short(s) => s,
305 ServiceSecretRef::Long { source, .. } => source,
306 }
307 }
308
309 pub fn target(&self) -> Option<&str> {
311 match self {
312 ServiceSecretRef::Short(_) => None,
313 ServiceSecretRef::Long { target, .. } => target.as_deref(),
314 }
315 }
316}
317
318#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
329 fn tmpfs_mode_octal_string_is_parsed_as_octal() {
330 let opts: TmpfsOptions = serde_yaml::from_str("mode: \"0700\"\n").unwrap();
333 assert_eq!(opts.mode, Some(0o700));
334 let opts: TmpfsOptions = serde_yaml::from_str("mode: \"0o755\"\n").unwrap();
336 assert_eq!(opts.mode, Some(0o755));
337 }
338
339 #[test]
340 fn tmpfs_mode_octal_yaml_literal_is_preserved_as_bits() {
341 let opts: TmpfsOptions = serde_yaml::from_str("mode: 0o700\n").unwrap();
344 assert_eq!(opts.mode, Some(0o700));
345 }
346
347 #[test]
348 fn tmpfs_mode_invalid_octal_is_clear_error() {
349 let err = serde_yaml::from_str::<TmpfsOptions>("mode: \"0o9\"\n").unwrap_err();
351 assert!(err.to_string().contains("octal notation"), "got: {err}");
352 }
353
354 #[test]
355 fn tmpfs_mode_bare_decimal_is_interpreted_as_octal() {
356 let opts: TmpfsOptions = serde_yaml::from_str("mode: 700\n").unwrap();
360 assert_eq!(opts.mode, Some(0o700));
361 let opts: TmpfsOptions = serde_yaml::from_str("mode: 644\n").unwrap();
362 assert_eq!(opts.mode, Some(0o644));
363 }
364
365 #[test]
366 fn int_mode_bits_treats_decoded_0o_literals_as_bits() {
367 assert_eq!(int_mode_bits(700), 0o700);
371 assert_eq!(int_mode_bits(0o755), 0o755);
372 assert_eq!(int_mode_bits(0o700), 0o700);
373 }
374
375 #[test]
378 fn volume_mount_short_two_parts_returns_second() {
379 let m = VolumeMount::Short("./data:/app/data".to_string());
380 assert_eq!(m.target(), "/app/data");
381 }
382
383 #[test]
384 fn volume_mount_short_three_parts_returns_second() {
385 let m = VolumeMount::Short("./data:/app/data:ro".to_string());
386 assert_eq!(m.target(), "/app/data");
387 }
388
389 #[test]
390 fn volume_mount_short_no_colon_returns_whole_string() {
391 let m = VolumeMount::Short("/app/data".to_string());
392 assert_eq!(m.target(), "/app/data");
393 }
394
395 #[test]
396 fn volume_mount_long_returns_target_field() {
397 let m = VolumeMount::Long {
398 volume_type: VolumeType::Bind,
399 source: Some("/host/path".to_string()),
400 target: "/container/path".to_string(),
401 read_only: None,
402 bind: None,
403 volume: None,
404 tmpfs: None,
405 consistency: None,
406 };
407 assert_eq!(m.target(), "/container/path");
408 }
409
410 #[test]
413 fn config_ref_short_source() {
414 let r = ServiceConfigRef::Short("my-config".to_string());
415 assert_eq!(r.source(), "my-config");
416 assert!(r.target().is_none());
417 }
418
419 #[test]
420 fn config_ref_long_source_and_target() {
421 let r = ServiceConfigRef::Long {
422 source: "my-config".to_string(),
423 target: Some("/run/configs/my-config".to_string()),
424 uid: None,
425 gid: None,
426 mode: None,
427 };
428 assert_eq!(r.source(), "my-config");
429 assert_eq!(r.target(), Some("/run/configs/my-config"));
430 }
431
432 #[test]
433 fn config_ref_long_no_target() {
434 let r = ServiceConfigRef::Long {
435 source: "my-config".to_string(),
436 target: None,
437 uid: None,
438 gid: None,
439 mode: None,
440 };
441 assert!(r.target().is_none());
442 }
443
444 #[test]
447 fn secret_ref_short_source() {
448 let r = ServiceSecretRef::Short("my-secret".to_string());
449 assert_eq!(r.source(), "my-secret");
450 assert!(r.target().is_none());
451 }
452
453 #[test]
454 fn secret_ref_long_source_and_target() {
455 let r = ServiceSecretRef::Long {
456 source: "my-secret".to_string(),
457 target: Some("/run/secrets/my-secret".to_string()),
458 uid: None,
459 gid: None,
460 mode: None,
461 };
462 assert_eq!(r.source(), "my-secret");
463 assert_eq!(r.target(), Some("/run/secrets/my-secret"));
464 }
465
466 #[test]
467 fn secret_ref_long_no_target() {
468 let r = ServiceSecretRef::Long {
469 source: "my-secret".to_string(),
470 target: None,
471 uid: None,
472 gid: None,
473 mode: None,
474 };
475 assert_eq!(r.source(), "my-secret");
476 assert!(r.target().is_none());
477 }
478}