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;