podup 1.7.1

Translate and run docker-compose files on rootless Podman
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
//! Volume, secret, and config mount types.
//!
//! [`VolumeMount`] covers the `volumes:` list on a service (short and long forms).
//! [`VolumeConfig`] describes top-level named volume definitions.
//! [`ServiceSecretRef`] and [`ServiceConfigRef`] are the per-service `secrets:` /
//! `configs:` attachment points (short = just the name, long = full options).

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use super::Labels;

/// Volume mount type: `volume`, `bind`, `tmpfs`, `npipe`, or `cluster`.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum VolumeType {
	/// A named or anonymous managed volume.
	Volume,
	/// A host path bind mount.
	Bind,
	/// An in-memory tmpfs mount.
	Tmpfs,
	/// A Windows named-pipe mount.
	Npipe,
	/// A cluster (Swarm) volume.
	Cluster,
}

/// Sub-options for a `bind`-type volume mount.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct BindOptions {
	/// Mount propagation mode (e.g. `rprivate`, `rshared`).
	#[serde(skip_serializing_if = "Option::is_none")]
	pub propagation: Option<String>,
	/// Whether to create the host path if it does not exist.
	#[serde(skip_serializing_if = "Option::is_none")]
	pub create_host_path: Option<bool>,
	/// SELinux relabeling option (`z` shared or `Z` private).
	#[serde(skip_serializing_if = "Option::is_none")]
	pub selinux: Option<String>,
}

/// Sub-options for a `volume`-type mount — nocopy flag and optional driver config.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct VolumeOptions {
	/// Whether to skip copying existing target contents into the volume.
	#[serde(skip_serializing_if = "Option::is_none")]
	pub nocopy: Option<bool>,
	/// Labels applied to the volume.
	#[serde(default)]
	pub labels: Labels,
	/// Volume driver name and options.
	#[serde(skip_serializing_if = "Option::is_none")]
	pub driver_config: Option<DriverConfig>,
	/// Path within the volume to mount instead of its root.
	#[serde(skip_serializing_if = "Option::is_none")]
	pub subpath: Option<String>,
}

/// Driver name and key-value options nested under `VolumeOptions`.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct DriverConfig {
	/// Volume driver name.
	#[serde(skip_serializing_if = "Option::is_none")]
	pub name: Option<String>,
	/// Driver-specific options.
	#[serde(default, skip_serializing_if = "HashMap::is_empty")]
	pub options: HashMap<String, String>,
}

/// Sub-options for a `tmpfs`-type mount — size and mode.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TmpfsOptions {
	/// Size of the tmpfs mount in bytes.
	#[serde(skip_serializing_if = "Option::is_none")]
	pub size: Option<u64>,
	/// File mode of the tmpfs mount, stored as the actual permission bits.
	///
	/// A compose `mode:` is conventionally octal, so every spelling is normalised
	/// to the same permission bits: a leading-zero string (`0700`), an explicit
	/// `0o700`, and a bare `700` all yield `0o700` (448). Invalid input is a clear
	/// error rather than a silent re-interpretation.
	#[serde(
		default,
		deserialize_with = "deserialize_octal_mode",
		skip_serializing_if = "Option::is_none"
	)]
	pub mode: Option<u32>,
}

/// Deserialize a tmpfs `mode` as octal-aware permission bits.
///
/// A compose `mode:` is conventionally octal, but serde_yaml decodes the spelling
/// before we see it: a `0o`-prefixed literal arrives as its numeric value
/// (`0o700` → 448), a leading-zero literal (`0700`) arrives as a string, and a
/// bare `700` arrives unchanged. We normalise all of them to the actual
/// permission bits (see [`int_mode_bits`] for the integer case), so the renderer
/// can octal-encode a single canonical value. A string is parsed as octal,
/// accepting an optional `0o` prefix and rejecting non-octal digits with a clear
/// message.
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"
				))
			})
		}
	}
}

/// Interpret a bare YAML integer `mode:` as octal permission bits.
///
/// A bare `700` is the octal file-mode the user typed, so its decimal digits are
/// read as octal (`700` → `0o700` = 448). A digit of `8` or `9` cannot be a real
/// octal digit, so such a value can only be a `0o` literal that serde_yaml has
/// already decoded to its numeric value (`0o700` → 448, `0o755` → 493); it is
/// taken as the actual permission bits verbatim. The same fallback covers an
/// out-of-range octal reading, so the conversion never panics.
fn int_mode_bits(n: u32) -> u32 {
	u32::from_str_radix(&n.to_string(), 8).unwrap_or(n)
}

/// A volume mount entry — either a short-form string or a long-form typed block.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
pub enum VolumeMount {
	/// Short form: a `source:target[:options]` string.
	Short(String),
	/// Long form: an explicitly typed mount with per-type options.
	Long {
		/// Mount type selecting which options block applies.
		#[serde(rename = "type")]
		volume_type: VolumeType,
		/// Mount source — host path, volume name, or omitted for anonymous/tmpfs.
		#[serde(default, skip_serializing_if = "Option::is_none")]
		source: Option<String>,
		/// Mount target path inside the container.
		target: String,
		/// Whether the mount is read-only.
		#[serde(default, skip_serializing_if = "Option::is_none")]
		read_only: Option<bool>,
		/// Bind-specific options, when `volume_type` is `bind`.
		#[serde(default, skip_serializing_if = "Option::is_none")]
		bind: Option<BindOptions>,
		/// Volume-specific options, when `volume_type` is `volume`.
		#[serde(default, skip_serializing_if = "Option::is_none")]
		volume: Option<VolumeOptions>,
		/// Tmpfs-specific options, when `volume_type` is `tmpfs`.
		#[serde(default, skip_serializing_if = "Option::is_none")]
		tmpfs: Option<TmpfsOptions>,
		/// Mount consistency requirement (a no-op outside Docker Desktop).
		#[serde(default, skip_serializing_if = "Option::is_none")]
		consistency: Option<String>,
	},
}

impl VolumeMount {
	/// Returns the container-side target path of the mount.
	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,
		}
	}
}

/// Named volume definition in the top-level `volumes:` block.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[non_exhaustive]
pub struct VolumeConfig {
	/// Volume driver name; the runtime default is used if absent.
	#[serde(skip_serializing_if = "Option::is_none")]
	pub driver: Option<String>,
	/// Driver-specific options.
	#[serde(default, skip_serializing_if = "HashMap::is_empty")]
	pub driver_opts: HashMap<String, String>,
	/// Whether the volume is externally managed and not created by podup.
	#[serde(skip_serializing_if = "Option::is_none")]
	pub external: Option<bool>,
	/// Custom volume name overriding the project-prefixed default.
	#[serde(skip_serializing_if = "Option::is_none")]
	pub name: Option<String>,
	/// Labels applied to the volume.
	#[serde(default)]
	pub labels: Labels,
	/// Unrecognized keys preserved verbatim for round-tripping.
	#[serde(flatten, default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
	pub unknown: indexmap::IndexMap<String, serde_yaml::Value>,
}

/// Reference to a named config from a service — short form (name only) or long form with mount target.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ServiceConfigRef {
	/// Short form: the name of a top-level config to mount.
	Short(String),
	/// Long form: a config name with mount target and ownership options.
	Long {
		/// Name of the top-level config to mount.
		source: String,
		/// Mount path inside the container; defaults to `/<source>` if absent.
		#[serde(default, skip_serializing_if = "Option::is_none")]
		target: Option<String>,
		/// Owner UID of the mounted file.
		#[serde(default, skip_serializing_if = "Option::is_none")]
		uid: Option<String>,
		/// Owner GID of the mounted file.
		#[serde(default, skip_serializing_if = "Option::is_none")]
		gid: Option<String>,
		/// File permission mode of the mounted file.
		#[serde(default, skip_serializing_if = "Option::is_none")]
		mode: Option<u32>,
	},
}

impl ServiceConfigRef {
	/// Returns the name of the referenced top-level config.
	pub fn source(&self) -> &str {
		match self {
			ServiceConfigRef::Short(s) => s,
			ServiceConfigRef::Long { source, .. } => source,
		}
	}

	/// Returns the container mount target, if specified.
	pub fn target(&self) -> Option<&str> {
		match self {
			ServiceConfigRef::Short(_) => None,
			ServiceConfigRef::Long { target, .. } => target.as_deref(),
		}
	}
}

/// Reference to a named secret from a service — short form (name only) or long form with mount target.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ServiceSecretRef {
	/// Short form: the name of a top-level secret to mount.
	Short(String),
	/// Long form: a secret name with mount target and ownership options.
	Long {
		/// Name of the top-level secret to mount.
		source: String,
		/// Mount path; defaults to `/run/secrets/<source>` if absent.
		#[serde(default, skip_serializing_if = "Option::is_none")]
		target: Option<String>,
		/// Owner UID of the mounted file.
		#[serde(default, skip_serializing_if = "Option::is_none")]
		uid: Option<String>,
		/// Owner GID of the mounted file.
		#[serde(default, skip_serializing_if = "Option::is_none")]
		gid: Option<String>,
		/// File permission mode of the mounted file.
		#[serde(default, skip_serializing_if = "Option::is_none")]
		mode: Option<u32>,
	},
}

impl ServiceSecretRef {
	/// Returns the name of the referenced top-level secret.
	pub fn source(&self) -> &str {
		match self {
			ServiceSecretRef::Short(s) => s,
			ServiceSecretRef::Long { source, .. } => source,
		}
	}

	/// Returns the container mount target, if specified.
	pub fn target(&self) -> Option<&str> {
		match self {
			ServiceSecretRef::Short(_) => None,
			ServiceSecretRef::Long { target, .. } => target.as_deref(),
		}
	}
}

// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
	use super::*;

	// TmpfsOptions::mode octal parsing

	#[test]
	fn tmpfs_mode_octal_string_is_parsed_as_octal() {
		// A leading-zero literal reaches us as a string and must parse as octal
		// (0700 → 448 permission bits) instead of failing opaquely.
		let opts: TmpfsOptions = serde_yaml::from_str("mode: \"0700\"\n").unwrap();
		assert_eq!(opts.mode, Some(0o700));
		// An explicit 0o prefix in a string also works.
		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() {
		// A YAML `0o700` scalar is decoded to 448 by the parser; we keep those
		// actual permission bits so the renderer's octal format round-trips.
		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() {
		// A non-octal string is rejected with a clear error, not silently coerced.
		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() {
		// A bare `700` is the octal file-mode the user typed, not a decimal value:
		// it must yield the same permission bits as `0700`/`0o700` (issue #917)
		// instead of being octal-encoded a second time at render time.
		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() {
		// A value carrying an 8/9 digit can only be a `0o` literal serde_yaml
		// already decoded (`0o755` → 493), so it is taken as the bits verbatim;
		// a value of valid octal digits is read as the octal the user typed.
		assert_eq!(int_mode_bits(700), 0o700);
		assert_eq!(int_mode_bits(0o755), 0o755);
		assert_eq!(int_mode_bits(0o700), 0o700);
	}

	// VolumeMount::target

	#[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");
	}

	// ServiceConfigRef

	#[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());
	}

	// ServiceSecretRef

	#[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());
	}
}