mod render;
mod unit;
mod warnings;
use crate::compose::types::ComposeFile;
use unit::{build_unit, container_unit, network_unit, volume_unit};
fn marker_owner(contents: &str) -> Option<&str> {
contents
.lines()
.find_map(|line| line.strip_prefix("# podup-owner: "))
}
fn guard_existing_owner(
path: &std::path::Path,
filename: &str,
contents: &str,
) -> std::io::Result<()> {
let Ok(existing) = std::fs::read_to_string(path) else {
return Ok(());
};
match (marker_owner(&existing), marker_owner(contents)) {
(Some(existing_owner), Some(new_owner)) if existing_owner != new_owner => {
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"refusing to overwrite {filename}: it belongs to project '{existing_owner}', not '{new_owner}'"
),
))
}
(None, _) => {
tracing::warn!("overwriting {filename}, which carries no podup ownership marker");
Ok(())
}
_ => Ok(()),
}
}
fn write_unit_file(path: &std::path::Path, contents: &str) -> std::io::Result<()> {
std::fs::write(path, contents)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct QuadletUnit {
pub filename: String,
pub contents: String,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct QuadletOutput {
pub units: Vec<QuadletUnit>,
pub warnings: Vec<String>,
}
impl QuadletOutput {
pub fn duplicate_filename(&self) -> Option<&str> {
let mut seen = std::collections::HashSet::new();
self.units
.iter()
.find(|u| !seen.insert(u.filename.as_str()))
.map(|u| u.filename.as_str())
}
}
pub fn write_units(
dir: &std::path::Path,
units: &[QuadletUnit],
) -> std::io::Result<Vec<std::path::PathBuf>> {
std::fs::create_dir_all(dir)?;
let mut written = Vec::with_capacity(units.len());
for unit in units {
if std::path::Path::new(&unit.filename).file_name()
!= Some(std::ffi::OsStr::new(&unit.filename))
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("refusing unsafe quadlet unit file name: {}", unit.filename),
));
}
let path = dir.join(&unit.filename);
guard_existing_owner(&path, &unit.filename, &unit.contents)?;
write_unit_file(&path, &unit.contents)?;
written.push(path);
}
Ok(written)
}
pub fn generate(file: &ComposeFile, project: &str) -> QuadletOutput {
generate_at(file, project, &std::env::current_dir().unwrap_or_default())
}
pub fn generate_at(file: &ComposeFile, project: &str, base_dir: &std::path::Path) -> QuadletOutput {
let mut out = QuadletOutput::default();
for (name, cfg) in &file.networks {
if cfg.as_ref().is_some_and(|c| c.external == Some(true)) {
continue;
}
out.units.push(network_unit(name, project, cfg.as_ref()));
if let Some(c) = cfg {
if let Some(ipam) = &c.ipam {
if !ipam.options.is_empty() {
out.warnings.push(format!(
"network '{name}': ipam.options have no Quadlet key and are not emitted; \
the live engine forwards them but `generate` cannot"
));
}
}
}
}
for (name, cfg) in &file.volumes {
if cfg.as_ref().is_some_and(|c| c.external == Some(true)) {
continue;
}
out.units.push(volume_unit(name, project, cfg.as_ref()));
}
let declared_volumes: Vec<&str> = file
.volumes
.iter()
.filter(|(_, cfg)| cfg.as_ref().is_none_or(|c| c.external != Some(true)))
.map(|(name, _)| name.as_str())
.collect();
let declared_networks: Vec<&str> = file
.networks
.iter()
.filter(|(_, cfg)| cfg.as_ref().is_none_or(|c| c.external != Some(true)))
.map(|(name, _)| name.as_str())
.collect();
for (name, service) in &file.services {
if let Some(unit) = build_unit(name, project, service, base_dir, &mut out.warnings) {
out.units.push(unit);
}
out.units.push(container_unit(
name,
project,
service,
&declared_volumes,
&declared_networks,
&file.secrets,
&mut out.warnings,
));
}
out
}
#[cfg(test)]
mod tests;
#[cfg(all(test, unix))]
mod write_guard_tests {
use std::os::unix::fs::PermissionsExt;
use super::{write_units, QuadletUnit};
fn unit(filename: &str, owner: &str) -> QuadletUnit {
QuadletUnit {
filename: filename.to_string(),
contents: format!(
"# podup-owner: {owner}\n[Container]\nEnvironment=PGPASSWORD=hunter2\n"
),
}
}
#[test]
fn refuses_to_overwrite_a_sibling_projects_unit() {
let dir = tempfile::tempdir().expect("tempdir");
write_units(dir.path(), &[unit("app-extra-web.container", "app-extra")]).expect("first");
let err = write_units(dir.path(), &[unit("app-extra-web.container", "app")])
.expect_err("must refuse");
assert!(
format!("{err}").contains("belongs to project 'app-extra'"),
"got: {err}"
);
let kept =
std::fs::read_to_string(dir.path().join("app-extra-web.container")).expect("read");
assert!(
kept.contains("# podup-owner: app-extra"),
"the original owner's marker must survive"
);
}
#[test]
fn rewriting_your_own_unit_is_allowed() {
let dir = tempfile::tempdir().expect("tempdir");
write_units(dir.path(), &[unit("app-web.container", "app")]).expect("first");
write_units(dir.path(), &[unit("app-web.container", "app")]).expect("second");
}
#[test]
fn units_are_written_private_because_they_carry_environment_values() {
let dir = tempfile::tempdir().expect("tempdir");
let written = write_units(dir.path(), &[unit("app-web.container", "app")]).expect("write");
let mode = std::fs::metadata(&written[0])
.expect("stat")
.permissions()
.mode() & 0o777;
assert_eq!(
mode, 0o600,
"a unit holding Environment= secrets must not be world-readable"
);
}
#[test]
fn an_existing_unit_written_by_an_older_podup_is_tightened() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("app-web.container");
std::fs::write(&path, "[Container]\n").expect("seed");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
write_units(dir.path(), &[unit("app-web.container", "app")]).expect("write");
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"re-installing must tighten a unit left loose by an older version"
);
}
}