use crate::compose::types::{DependsOn, Service, ServiceCondition};
pub(super) fn collect_warnings(name: &str, service: &Service, warnings: &mut Vec<String>) {
let mut warn = |field: &str, detail: &str| {
warnings.push(format!("{name}: {field} {detail}"));
};
let replicas = service
.scale
.or(service.deploy.as_ref().and_then(|d| d.replicas));
if replicas.is_some_and(|r| r > 1) {
warn(
"scale/replicas",
"is ignored; Quadlet emits a single container per service",
);
}
if !service.configs.is_empty() {
warn("configs", "have no Quadlet equivalent and are skipped");
}
if !service.volumes_from.is_empty() {
warn("volumes_from", "has no Quadlet equivalent and is skipped");
}
if service.network_mode.as_deref().is_some_and(|m| {
m != "host" && m != "none" && !m.starts_with("service:") && !m.starts_with("container:")
}) {
warn(
"network_mode",
"is not mapped (only `host`/`none`/`service:`/`container:` are supported); use networks instead",
);
}
if !service.profiles.is_empty() {
warn("profiles", "have no Quadlet equivalent and are ignored");
}
if !service.post_start.is_empty() {
warn(
"post_start",
"hooks have no Quadlet equivalent and are skipped",
);
}
if !service.pre_stop.is_empty() {
warn(
"pre_stop",
"hooks have no Quadlet equivalent and are skipped",
);
}
if let DependsOn::Map(deps) = &service.depends_on {
if deps
.values()
.any(|c| c.condition != ServiceCondition::ServiceStarted)
{
warn(
"depends_on",
"condition service_healthy/service_completed_successfully is not enforceable in Quadlet; only start ordering is emitted",
);
}
}
let skipped = "has no Quadlet equivalent and is skipped";
if service.ipc.is_some() {
warn("ipc", skipped);
}
if service.pid.is_some() {
warn("pid", skipped);
}
if service.uts.is_some() {
warn("uts", skipped);
}
if service.cgroup.is_some() {
warn("cgroup", skipped);
}
if service.cgroup_parent.is_some() {
warn("cgroup_parent", skipped);
}
if service.runtime.is_some() {
warn("runtime", skipped);
}
if service.tty.is_some() {
warn("tty", skipped);
}
if service.stdin_open.is_some() {
warn("stdin_open", skipped);
}
if service.memswap_limit.is_some() {
warn("memswap_limit", skipped);
}
if service.mem_reservation.is_some() {
warn("mem_reservation", skipped);
}
if service.oom_kill_disable.is_some() {
warn("oom_kill_disable", skipped);
}
if service.oom_score_adj.is_some() {
warn("oom_score_adj", skipped);
}
if service.blkio_config.is_some() {
warn("blkio_config", skipped);
}
if service.gpus.is_some() {
warn(
"gpus",
"has no Quadlet equivalent and is skipped; GPU devices are not assigned",
);
}
if service.platform.is_some() {
warn("platform", skipped);
}
if !service.device_cgroup_rules.is_empty() {
warn("device_cgroup_rules", skipped);
}
if !service.storage_opt.is_empty() {
warn("storage_opt", skipped);
}
if !service.links.is_empty() {
warn("links", skipped);
}
if !service.external_links.is_empty() {
warn("external_links", skipped);
}
if service.domainname.is_some() {
warn("domainname", skipped);
}
if service.mem_swappiness.is_some() {
warn("mem_swappiness", skipped);
}
if service.cpu_rt_runtime.is_some() {
warn("cpu_rt_runtime", skipped);
}
if service.cpu_rt_period.is_some() {
warn("cpu_rt_period", skipped);
}
if service.cpu_count.is_some() {
warn("cpu_count", skipped);
}
if service.cpu_percent.is_some() {
warn("cpu_percent", skipped);
}
if service.attach.is_some() {
warn("attach", skipped);
}
if service.develop.is_some() {
warn("develop", skipped);
}
if service.credential_spec.is_some() {
warn("credential_spec", skipped);
}
if service.isolation.is_some() {
warn("isolation", skipped);
}
if service.provider.is_some() {
warn("provider", skipped);
}
if service.use_api_socket.is_some() {
warn("use_api_socket", skipped);
}
if !service.label_file.to_list().is_empty() {
warn("label_file", skipped);
}
let has_network_mac = service
.networks
.names()
.iter()
.filter_map(|n| service.networks.config_for(n))
.any(|c| c.mac_address.is_some());
if service.mac_address.is_some() || has_network_mac {
warn("mac_address", skipped);
}
let static_ip_count = service
.networks
.names()
.iter()
.filter_map(|n| service.networks.config_for(n))
.filter(|c| c.ipv4_address.is_some() || c.ipv6_address.is_some())
.count();
if static_ip_count > 1 {
warn(
"ipv4_address/ipv6_address",
"is set on multiple networks; Quadlet emits only the first (no per-network IP scoping)",
);
}
}
#[cfg(test)]
mod tests {
use crate::parse_str;
use crate::quadlet::generate;
#[test]
fn warns_for_every_unmapped_field() {
let yaml = r#"
services:
everything:
image: app:1.0
build: .
scale: 3
network_mode: "bridge:custom"
volumes_from:
- other
profiles:
- debug
healthcheck:
test: ["CMD", "true"]
secrets:
- my_secret
configs:
- my_config
secrets:
my_secret:
file: ./s.txt
configs:
my_config:
file: ./c.txt
"#;
let file = parse_str(yaml).unwrap();
let warnings = generate(&file, "proj").warnings;
let joined = warnings.join("\n");
for field in [
"scale/replicas",
"configs",
"volumes_from",
"network_mode",
"profiles",
] {
assert!(
joined.contains(field),
"missing warning for {field}; got:\n{joined}"
);
}
assert!(
!joined.contains("secrets"),
"secrets should be mapped, not warned; got:\n{joined}"
);
assert!(
!joined.contains("privileged"),
"privileged should be mapped, not warned; got:\n{joined}"
);
}
#[test]
fn service_and_container_network_modes_are_mapped_not_warned() {
for mode in ["service:db", "container:other"] {
let yaml = format!("services:\n s:\n image: x\n network_mode: \"{mode}\"\n");
let file = parse_str(&yaml).unwrap();
let joined = generate(&file, "proj").warnings.join("\n");
assert!(
!joined.contains("network_mode"),
"{mode} should be mapped, not warned; got:\n{joined}"
);
}
}
#[test]
fn warns_for_silently_dropped_runtime_fields() {
let yaml = r#"
services:
s:
image: x
ipc: host
pid: host
uts: host
cgroup: private
cgroup_parent: /sys/fs/cgroup/p
runtime: crun
tty: true
stdin_open: true
mac_address: "02:42:ac:11:00:02"
memswap_limit: 1g
mem_reservation: 256m
oom_kill_disable: true
oom_score_adj: -500
label_file:
- ./labels.env
blkio_config:
weight: 300
"#;
let file = parse_str(yaml).unwrap();
let joined = generate(&file, "proj").warnings.join("\n");
for field in [
"ipc",
"pid",
"uts",
"cgroup",
"cgroup_parent",
"runtime",
"tty",
"stdin_open",
"mac_address",
"memswap_limit",
"mem_reservation",
"oom_kill_disable",
"oom_score_adj",
"label_file",
"blkio_config",
] {
assert!(
joined.contains(field),
"missing warning for {field}; got:\n{joined}"
);
}
}
#[test]
fn warns_for_additional_silently_dropped_fields() {
let yaml = r#"
services:
s:
image: x
gpus: all
platform: linux/arm64
domainname: example.internal
links:
- other
external_links:
- ext:alias
device_cgroup_rules:
- "c 1:3 rwm"
storage_opt:
size: 10G
mem_swappiness: 50
cpu_rt_runtime: 95000
cpu_rt_period: 1000000
"#;
let file = parse_str(yaml).unwrap();
let joined = generate(&file, "proj").warnings.join("\n");
for field in [
"gpus",
"platform",
"domainname",
"links",
"external_links",
"device_cgroup_rules",
"storage_opt",
"mem_swappiness",
"cpu_rt_runtime",
"cpu_rt_period",
] {
assert!(
joined.contains(field),
"missing warning for {field}; got:\n{joined}"
);
}
}
#[test]
fn security_opt_mask_and_unmask_map_to_keys_not_warned() {
let yaml = r#"
services:
s:
image: x
security_opt:
- "mask=/proc/kcore:/proc/timer_list"
- "unmask=ALL"
"#;
let file = parse_str(yaml).unwrap();
let out = generate(&file, "proj");
let contents = out
.units
.iter()
.map(|u| u.contents.as_str())
.collect::<String>();
assert!(
contents.contains("Mask=/proc/kcore:/proc/timer_list"),
"missing Mask= key; got:\n{contents}"
);
assert!(
contents.contains("Unmask=ALL"),
"missing Unmask= key; got:\n{contents}"
);
assert!(
!out.warnings.iter().any(|w| w.contains("security_opt")),
"mask/unmask should be mapped, not warned; got:\n{:?}",
out.warnings
);
}
#[test]
fn warns_when_static_ip_set_on_multiple_networks() {
let yaml = r#"
services:
s:
image: x
networks:
a:
ipv4_address: 10.0.0.2
b:
ipv4_address: 10.0.1.2
networks:
a:
b:
"#;
let file = parse_str(yaml).unwrap();
let joined = generate(&file, "proj").warnings.join("\n");
assert!(
joined.contains("ipv4_address/ipv6_address"),
"missing multi-network static-IP warning; got:\n{joined}"
);
}
#[test]
fn warns_for_parsed_but_unmapped_service_fields() {
let yaml = r#"
services:
s:
image: x
cpu_count: 2
cpu_percent: 50
attach: false
develop:
watch:
- path: ./src
action: sync
target: /app
credential_spec:
file: cred.json
isolation: process
provider:
type: terraform
use_api_socket: true
"#;
let file = parse_str(yaml).unwrap();
let joined = generate(&file, "proj").warnings.join("\n");
for field in [
"cpu_count",
"cpu_percent",
"attach",
"develop",
"credential_spec",
"isolation",
"provider",
"use_api_socket",
] {
assert!(
joined.contains(field),
"missing warning for {field}; got:\n{joined}"
);
}
}
#[test]
fn clean_service_warns_about_nothing() {
let yaml = r#"
services:
web:
image: nginx:1.27
"#;
let file = parse_str(yaml).unwrap();
assert!(generate(&file, "proj").warnings.is_empty());
}
}