use super::types::{BuildConfig, ComposeFile};
pub(super) fn collect(file: &ComposeFile) -> Vec<String> {
let mut out = Vec::new();
unknown_top_level_keys(file, &mut out);
unknown_service_keys(file, &mut out);
nested_unknown_keys(file, &mut out);
ignored_service_fields(file, &mut out);
ignored_build_fields(file, &mut out);
ignored_network_fields(file, &mut out);
ignored_service_network_fields(file, &mut out);
out
}
fn push_unknown(
context: &str,
unknown: &indexmap::IndexMap<String, serde_yaml::Value>,
out: &mut Vec<String>,
) {
for key in unknown.keys() {
if key.starts_with("x-") {
continue;
}
out.push(format!(
"{context}: unknown key '{key}' is ignored \
(check for a typo or an unsupported compose feature)"
));
}
}
fn nested_unknown_keys(file: &ComposeFile, out: &mut Vec<String>) {
for (service, def) in &file.services {
if let Some(hc) = &def.healthcheck {
push_unknown(
&format!("service '{service}' healthcheck"),
&hc.unknown,
out,
);
}
if let Some(deploy) = &def.deploy {
push_unknown(&format!("service '{service}' deploy"), &deploy.unknown, out);
}
if let Some(develop) = &def.develop {
for (i, rule) in develop.watch.iter().enumerate() {
push_unknown(
&format!("service '{service}' develop.watch[{i}]"),
&rule.unknown,
out,
);
}
}
}
for (name, cfg) in &file.networks {
if let Some(c) = cfg {
push_unknown(&format!("network '{name}'"), &c.unknown, out);
if let Some(ipam) = &c.ipam {
push_unknown(&format!("network '{name}' ipam"), &ipam.unknown, out);
}
}
}
for (name, cfg) in &file.volumes {
if let Some(c) = cfg {
push_unknown(&format!("volume '{name}'"), &c.unknown, out);
}
}
}
fn unknown_top_level_keys(file: &ComposeFile, out: &mut Vec<String>) {
for key in file.extensions.keys() {
if key.starts_with("x-") {
continue;
}
out.push(format!(
"unknown top-level key '{key}' is ignored \
(check for a typo or an unsupported compose feature)"
));
}
}
fn unknown_service_keys(file: &ComposeFile, out: &mut Vec<String>) {
for (service, def) in &file.services {
for key in def.unknown.keys() {
if key.starts_with("x-") {
continue;
}
out.push(format!(
"service '{service}': unknown key '{key}' is ignored \
(check for a typo or an unsupported compose feature)"
));
}
}
}
fn ignored_service_fields(file: &ComposeFile, out: &mut Vec<String>) {
for (service, def) in &file.services {
if def.cpu_count.is_some() {
out.push(format!(
"service '{service}': cpu_count is a Windows/Hyper-V control with no \
rootless Podman equivalent and is ignored"
));
}
if def.cpu_percent.is_some() {
out.push(format!(
"service '{service}': cpu_percent is a Windows/Hyper-V control with no \
rootless Podman equivalent and is ignored"
));
}
}
}
fn ignored_build_fields(file: &ComposeFile, out: &mut Vec<String>) {
for (service, def) in &file.services {
let Some(BuildConfig::Config {
privileged,
ulimits,
isolation,
entitlements,
provenance,
sbom,
..
}) = &def.build
else {
continue;
};
let mut unmapped: Vec<&str> = Vec::new();
if privileged.is_some() {
unmapped.push("privileged");
}
if !ulimits.is_empty() {
unmapped.push("ulimits");
}
if isolation.is_some() {
unmapped.push("isolation");
}
if !entitlements.is_empty() {
unmapped.push("entitlements");
}
if provenance.is_some() {
unmapped.push("provenance");
}
if sbom.is_some() {
unmapped.push("sbom");
}
for field in unmapped {
out.push(format!(
"service '{service}': build.{field} has no libpod build-API equivalent \
and is ignored"
));
}
}
}
fn ignored_network_fields(file: &ComposeFile, out: &mut Vec<String>) {
for (name, cfg) in &file.networks {
if let Some(c) = cfg {
if c.enable_ipv4.is_some() {
out.push(format!(
"network '{name}': enable_ipv4 is not forwarded; Podman networks \
enable IPv4 by default and expose no toggle"
));
}
}
}
}
fn ignored_service_network_fields(file: &ComposeFile, out: &mut Vec<String>) {
for (service, def) in &file.services {
for name in def.networks.names() {
if let Some(c) = def.networks.config_for(&name) {
if c.gw_priority.is_some() {
out.push(format!(
"service '{service}' network '{name}': gw_priority is not supported \
by Podman and is ignored"
));
}
}
}
}
}
#[cfg(test)]
mod tests {
use crate::parse_str;
fn diagnostics_for(yaml: &str) -> Vec<String> {
let file = parse_str(yaml).unwrap();
super::collect(&file)
}
#[test]
fn warns_on_unknown_top_level_key_but_not_x_extension() {
let msgs = diagnostics_for(
"x-anchors: ok\nservies:\n typo: 1\nservices:\n web:\n image: nginx\n",
);
assert!(
msgs.iter()
.any(|m| m.contains("unknown top-level key 'servies'")),
"got: {msgs:?}"
);
assert!(!msgs.iter().any(|m| m.contains("x-anchors")));
}
#[test]
fn warns_on_unknown_service_key_but_not_x_extension() {
let msgs = diagnostics_for(
"services:\n web:\n image: nginx\n enviroment:\n A: 1\n x-meta: ok\n",
);
assert!(msgs.iter().any(|m| m.contains("unknown key 'enviroment'")));
assert!(!msgs.iter().any(|m| m.contains("x-meta")));
}
#[test]
fn warns_on_windows_only_cpu_fields() {
let msgs = diagnostics_for(
"services:\n web:\n image: nginx\n cpu_count: 2\n cpu_percent: 50\n",
);
assert!(msgs.iter().any(|m| m.contains("cpu_count")));
assert!(msgs.iter().any(|m| m.contains("cpu_percent")));
}
#[test]
fn warns_on_unmapped_build_fields() {
let msgs = diagnostics_for(
"services:\n web:\n build:\n context: .\n privileged: true\n isolation: chroot\n",
);
assert!(msgs.iter().any(|m| m.contains("build.privileged")));
assert!(msgs.iter().any(|m| m.contains("build.isolation")));
}
#[test]
fn warns_on_network_enable_ipv4() {
let msgs = diagnostics_for(
"services:\n web:\n image: nginx\nnetworks:\n net:\n enable_ipv4: false\n",
);
assert!(msgs.iter().any(|m| m.contains("enable_ipv4")));
}
#[test]
fn warns_on_unknown_key_in_healthcheck() {
let msgs = diagnostics_for(
"services:\n web:\n image: nginx\n healthcheck:\n test: [\"CMD\", \"true\"]\n retires: 3\n",
);
assert!(
msgs.iter()
.any(|m| m.contains("healthcheck") && m.contains("retires")),
"got: {msgs:?}"
);
}
#[test]
fn warns_on_unknown_key_in_network_and_ipam() {
let msgs = diagnostics_for(
"services:\n web:\n image: nginx\nnetworks:\n net:\n drivr: bridge\n ipam:\n confg: []\n",
);
assert!(msgs
.iter()
.any(|m| m.contains("network 'net'") && m.contains("drivr")));
assert!(msgs
.iter()
.any(|m| m.contains("ipam") && m.contains("confg")));
}
#[test]
fn warns_on_unknown_key_in_volume() {
let msgs = diagnostics_for(
"services:\n web:\n image: nginx\nvolumes:\n data:\n externl: true\n",
);
assert!(msgs
.iter()
.any(|m| m.contains("volume 'data'") && m.contains("externl")));
}
#[test]
fn warns_on_service_network_gw_priority() {
let msgs = diagnostics_for(
"services:\n web:\n image: nginx\n networks:\n net:\n gw_priority: 10\nnetworks:\n net:\n",
);
assert!(
msgs.iter().any(|m| m.contains("gw_priority")),
"got: {msgs:?}"
);
}
#[test]
fn file_secret_produces_no_diagnostics() {
let msgs = diagnostics_for(
"services:\n web:\n image: nginx\n secrets: [tok]\nsecrets:\n tok:\n file: ./tok.txt\n",
);
assert!(msgs.is_empty(), "unexpected diagnostics: {msgs:?}");
}
#[test]
fn clean_file_produces_no_diagnostics() {
let msgs = diagnostics_for("services:\n web:\n image: nginx\n cpu_shares: 512\n");
assert!(msgs.is_empty(), "unexpected diagnostics: {msgs:?}");
}
}