Skip to main content

podup/quadlet/
mod.rs

1//! Translate a parsed compose file into Podman Quadlet unit files.
2//!
3//! Quadlet is Podman's systemd integration: declarative `.container`,
4//! `.network` and `.volume` units placed under
5//! `~/.config/containers/systemd/` that a systemd generator turns into
6//! services, so systemd owns the lifecycle (boot, restart, dependencies)
7//! instead of a long-running `podup` process.
8//!
9//! This is an additive export path, not a replacement for the runner. It
10//! maps the common compose fields and warns — loudly, never silently — for
11//! every field that is set but has no Quadlet equivalent yet, so generated
12//! units never quietly drop configuration.
13
14mod render;
15mod unit;
16mod warnings;
17
18use crate::compose::types::ComposeFile;
19use unit::{build_unit, container_unit, network_unit, volume_unit};
20
21/// A single generated unit file: its name and full contents.
22#[derive(Debug, Clone, PartialEq, Eq)]
23#[non_exhaustive]
24pub struct QuadletUnit {
25	/// File name, e.g. `web.container` or `db-data.volume`.
26	pub filename: String,
27	/// Full file contents, ending in a newline.
28	pub contents: String,
29}
30
31/// The result of a generation run: the units plus any warnings about set but
32/// unmapped fields.
33#[derive(Debug, Clone, Default)]
34#[non_exhaustive]
35pub struct QuadletOutput {
36	/// Generated unit files, in a deterministic order.
37	pub units: Vec<QuadletUnit>,
38	/// Human-readable warnings for compose fields with no Quadlet mapping.
39	pub warnings: Vec<String>,
40}
41
42impl QuadletOutput {
43	/// The first unit file name that two different units share, if any. Distinct
44	/// compose keys can sanitize to the same stem (e.g. `web:1` and `web_1` both
45	/// become `web_1`); writing them would silently overwrite one unit, dropping
46	/// a service/network/volume from the export. Callers surface this as an error
47	/// instead of clobbering.
48	pub fn duplicate_filename(&self) -> Option<&str> {
49		let mut seen = std::collections::HashSet::new();
50		self.units
51			.iter()
52			.find(|u| !seen.insert(u.filename.as_str()))
53			.map(|u| u.filename.as_str())
54	}
55}
56
57/// Write `units` into `dir`, creating it if needed, and return the paths written
58/// in order. Defense in depth: refuse any unit whose file name is not a plain path
59/// component. The library already sanitizes stems, but a write target must never
60/// contain a separator, `.` or `..` that could escape `dir`. Shared by
61/// `generate quadlet -o` and `autostart --mode quadlet`, so both place units
62/// through the identical safety check.
63pub fn write_units(
64	dir: &std::path::Path,
65	units: &[QuadletUnit],
66) -> std::io::Result<Vec<std::path::PathBuf>> {
67	std::fs::create_dir_all(dir)?;
68	let mut written = Vec::with_capacity(units.len());
69	for unit in units {
70		if std::path::Path::new(&unit.filename).file_name()
71			!= Some(std::ffi::OsStr::new(&unit.filename))
72		{
73			return Err(std::io::Error::new(
74				std::io::ErrorKind::InvalidInput,
75				format!("refusing unsafe quadlet unit file name: {}", unit.filename),
76			));
77		}
78		let path = dir.join(&unit.filename);
79		std::fs::write(&path, &unit.contents)?;
80		written.push(path);
81	}
82	Ok(written)
83}
84
85/// Translate a compose file into Quadlet units for the given project name,
86/// resolving relative build contexts against the current directory (the common
87/// case: running from the project directory). Use [`generate_at`] to resolve them
88/// against an explicit base directory instead.
89pub fn generate(file: &ComposeFile, project: &str) -> QuadletOutput {
90	generate_at(file, project, &std::env::current_dir().unwrap_or_default())
91}
92
93/// As [`generate`], but resolves a service's relative `build:` context against
94/// `base_dir` (the compose file's directory) rather than the current directory.
95/// The systemd generator runs a `.build` unit with no cwd, so a unit written for
96/// it must carry an absolute `SetWorkingDirectory`; pass the compose base here.
97///
98/// Emits one `.container` per service, one `.network` per declared network,
99/// and one `.volume` per declared named volume. Replica scaling, inline-Dockerfile
100/// builds, and other fields without a Quadlet mapping are reported as warnings
101/// rather than silently dropped.
102pub fn generate_at(file: &ComposeFile, project: &str, base_dir: &std::path::Path) -> QuadletOutput {
103	let mut out = QuadletOutput::default();
104
105	// External networks/volumes are assumed to pre-exist. Emitting a unit would
106	// make systemd try to (re-)create them, so skip them here; the container unit
107	// references such resources by their existing name instead.
108	for (name, cfg) in &file.networks {
109		if cfg.as_ref().is_some_and(|c| c.external == Some(true)) {
110			continue;
111		}
112		out.units.push(network_unit(name, project, cfg.as_ref()));
113		// `podman network create` (and therefore Quadlet) exposes no key for IPAM
114		// options beyond the IPAM driver, so `ipam.options` cannot be emitted. The
115		// live engine forwards them via the libpod API directly, so warn rather than
116		// let `generate` silently diverge from `up`.
117		if let Some(c) = cfg {
118			if let Some(ipam) = &c.ipam {
119				if !ipam.options.is_empty() {
120					out.warnings.push(format!(
121						"network '{name}': ipam.options have no Quadlet key and are not emitted; \
122						 the live engine forwards them but `generate` cannot"
123					));
124				}
125			}
126		}
127	}
128	for (name, cfg) in &file.volumes {
129		if cfg.as_ref().is_some_and(|c| c.external == Some(true)) {
130			continue;
131		}
132		out.units.push(volume_unit(name, project, cfg.as_ref()));
133	}
134
135	let declared_volumes: Vec<&str> = file
136		.volumes
137		.iter()
138		.filter(|(_, cfg)| cfg.as_ref().is_none_or(|c| c.external != Some(true)))
139		.map(|(name, _)| name.as_str())
140		.collect();
141	let declared_networks: Vec<&str> = file
142		.networks
143		.iter()
144		.filter(|(_, cfg)| cfg.as_ref().is_none_or(|c| c.external != Some(true)))
145		.map(|(name, _)| name.as_str())
146		.collect();
147	for (name, service) in &file.services {
148		// Emit a `.build` unit first so the systemd generator builds the image
149		// before the container that references it via `Image=<stem>.build`.
150		if let Some(unit) = build_unit(name, project, service, base_dir, &mut out.warnings) {
151			out.units.push(unit);
152		}
153		out.units.push(container_unit(
154			name,
155			project,
156			service,
157			&declared_volumes,
158			&declared_networks,
159			&file.secrets,
160			&mut out.warnings,
161		));
162	}
163
164	out
165}
166
167#[cfg(test)]
168mod tests;