use std::io::Write;
use std::path::Path;
use podup::compose::types::ComposeFile;
fn quadlet_platform_advisory(os: &str) -> Option<String> {
(os != "linux").then(|| {
"quadlet units require systemd (Linux); generated files will not run on this host"
.to_string()
})
}
fn validate_for_quadlet(file: &ComposeFile) -> podup::Result<()> {
if let Err(e @ podup::ComposeError::CircularDependency(_)) = podup::compose::resolve_order(file)
{
return Err(e);
}
for (name, svc) in &file.services {
if svc.image.is_none() && svc.build.is_none() {
return Err(podup::ComposeError::NoImageOrBuild(name.clone()));
}
podup::ports::parse_ports(&svc.ports)?;
if let Some(mem) = &svc.mem_limit {
if podup::size::parse_memory(mem).is_none() {
return Err(podup::ComposeError::Unsupported(format!(
"service '{name}': mem_limit '{mem}' is not a valid memory size"
)));
}
}
}
Ok(())
}
fn write_stdout(buf: &str) -> podup::Result<()> {
match std::io::stdout().write_all(buf.as_bytes()) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(e.into()),
}
}
pub(crate) fn write_quadlet(
file: &podup::compose::types::ComposeFile,
project: &str,
output: Option<&Path>,
) -> podup::Result<()> {
validate_for_quadlet(file)?;
let result = podup::quadlet::generate(file, project);
if let Some(dup) = result.duplicate_filename() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"quadlet: two resources map to the same unit file {dup:?}; \
rename one so their names do not collide after sanitization"
),
)
.into());
}
if let Some(advisory) = quadlet_platform_advisory(std::env::consts::OS) {
eprintln!("podup: warning: {advisory}");
}
for warning in &result.warnings {
eprintln!("podup: warning: {warning}");
}
match output {
Some(dir) => {
std::fs::create_dir_all(dir)?;
let mut progress = String::new();
for unit in &result.units {
if 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),
)
.into());
}
let path = dir.join(&unit.filename);
std::fs::write(&path, &unit.contents)?;
progress.push_str(&format!("wrote {}\n", path.display()));
}
write_stdout(&progress)?;
}
None => {
let mut out = String::new();
for unit in &result.units {
out.push_str(&format!("# {}\n", unit.filename));
out.push_str(&unit.contents);
out.push('\n');
}
write_stdout(&out)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{quadlet_platform_advisory, validate_for_quadlet, write_quadlet};
use podup::parse_str;
#[test]
fn quadlet_advisory_only_on_non_linux() {
assert_eq!(quadlet_platform_advisory("linux"), None);
for os in ["macos", "windows", "freebsd"] {
let msg = quadlet_platform_advisory(os).expect("non-linux host warns");
assert!(msg.contains("systemd"), "advisory names the requirement");
}
}
#[test]
fn cyclic_depends_on_is_rejected_before_emitting_units() {
let file = podup::parse_str(
"services:\n a:\n image: x\n depends_on: [b]\n b:\n image: y\n depends_on: [a]\n",
)
.unwrap();
let err = write_quadlet(&file, "proj", None).unwrap_err();
assert!(matches!(err, podup::ComposeError::CircularDependency(_)));
}
#[test]
fn valid_compose_passes_validation() {
let file = parse_str("services:\n web:\n image: nginx\n").unwrap();
assert!(validate_for_quadlet(&file).is_ok());
}
#[test]
fn service_without_image_or_build_is_rejected() {
let file = parse_str("services:\n web:\n ports:\n - \"8080:80\"\n").unwrap();
let err = validate_for_quadlet(&file).unwrap_err();
assert!(matches!(err, podup::ComposeError::NoImageOrBuild(_)));
}
#[test]
fn out_of_range_port_is_rejected() {
let file = parse_str("services:\n web:\n image: x\n ports:\n - \"70000:80\"\n")
.unwrap();
assert!(validate_for_quadlet(&file).is_err());
}
#[test]
fn malformed_mem_limit_is_rejected() {
let file = parse_str("services:\n web:\n image: x\n mem_limit: abc\n").unwrap();
let err = validate_for_quadlet(&file).unwrap_err();
assert!(matches!(err, podup::ComposeError::Unsupported(_)));
}
#[test]
fn dependency_cycle_is_rejected() {
let yaml = "services:\n a:\n image: x\n depends_on: [b]\n b:\n image: x\n depends_on: [a]\n";
let file = parse_str(yaml).unwrap();
let err = validate_for_quadlet(&file).unwrap_err();
assert!(matches!(err, podup::ComposeError::CircularDependency(_)));
}
#[test]
fn missing_dependency_is_not_fatal() {
let file = parse_str("services:\n web:\n image: x\n depends_on: [db]\n").unwrap();
assert!(validate_for_quadlet(&file).is_ok());
}
}