Skip to content

Commit 0ca8bf4

Browse files
authored
Merge branch 'develop' into fix/libpod-spec-response-correctness
2 parents fc3203d + a266eea commit 0ca8bf4

7 files changed

Lines changed: 204 additions & 42 deletions

File tree

internal/quadlet/tests/units.rs

Lines changed: 122 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,7 @@ fn warns_for_every_unmapped_field() {
130130
services:
131131
s:
132132
image: x
133-
network_mode: "container:other"
134-
privileged: true
133+
network_mode: "bridge:custom"
135134
profiles: [debug]
136135
volumes_from:
137136
- other
@@ -141,13 +140,7 @@ services:
141140
let file = parse_str(yaml).unwrap();
142141
let out = generate(&file, "p");
143142
let joined = out.warnings.join("\n");
144-
for needle in [
145-
"network_mode",
146-
"privileged",
147-
"profiles",
148-
"volumes_from",
149-
"scale/replicas",
150-
] {
143+
for needle in ["network_mode", "profiles", "volumes_from", "scale/replicas"] {
151144
assert!(joined.contains(needle), "expected warning for {needle}");
152145
}
153146
}
@@ -334,3 +327,123 @@ volumes:
334327
// An option with no dedicated key falls back to PodmanArgs=--opt.
335328
assert!(vol.contains("PodmanArgs=--opt custom=extra"), "in:\n{vol}");
336329
}
330+
331+
#[test]
332+
fn healthcheck_start_interval_is_warned_and_omitted() {
333+
// `start_interval` has no Quadlet/Podman equivalent: it must not emit a
334+
// `HealthStartupInterval=` (which drives an unrelated, no-op startup
335+
// healthcheck) and must instead produce a warning.
336+
let yaml = r#"
337+
services:
338+
s:
339+
image: x
340+
healthcheck:
341+
test: ["CMD", "true"]
342+
interval: 5s
343+
start_interval: 2s
344+
"#;
345+
let file = parse_str(yaml).unwrap();
346+
let out = generate(&file, "p");
347+
let c = &unit_named(&out, "s.container").contents;
348+
assert!(c.contains("HealthInterval=5s"), "in:\n{c}");
349+
assert!(
350+
!c.contains("HealthStartupInterval"),
351+
"start_interval must not emit HealthStartupInterval=; got:\n{c}"
352+
);
353+
let joined = out.warnings.join("\n");
354+
assert!(
355+
joined.contains("start_interval"),
356+
"start_interval must warn; got:\n{joined}"
357+
);
358+
}
359+
360+
#[test]
361+
fn dependency_unit_names_are_sanitized_in_ordering() {
362+
// A dependency whose compose key sanitizes to a different stem must be
363+
// referenced by that stem in After=/Requires=, matching the generated unit.
364+
let yaml = r#"
365+
services:
366+
web:
367+
image: nginx
368+
depends_on:
369+
- "db:1"
370+
? "db:1"
371+
: { image: postgres }
372+
"#;
373+
let file = parse_str(yaml).unwrap();
374+
let out = generate(&file, "proj");
375+
let web = &unit_named(&out, "web.container").contents;
376+
assert!(web.contains("After=db_1.service"), "in:\n{web}");
377+
assert!(web.contains("Requires=db_1.service"), "in:\n{web}");
378+
// The raw, unsanitized name must not leak into the ordering directives.
379+
assert!(!web.contains("db:1.service"), "in:\n{web}");
380+
// The dependency's own unit really is named with the sanitized stem.
381+
unit_named(&out, "db_1.container");
382+
}
383+
384+
#[test]
385+
fn network_mode_service_and_container_map_to_dot_container() {
386+
// `network_mode: service:X` / `container:X` reuse another container's netns,
387+
// which Quadlet expresses as `Network={X}.container`.
388+
for (mode, target) in [("service:db", "db"), ("container:sidecar", "sidecar")] {
389+
let yaml = format!("services:\n s:\n image: x\n network_mode: \"{mode}\"\n");
390+
let file = parse_str(&yaml).unwrap();
391+
let out = generate(&file, "p");
392+
let c = &unit_named(&out, "s.container").contents;
393+
assert!(
394+
c.contains(&format!("Network={target}.container")),
395+
"{mode} must map to Network={target}.container; got:\n{c}"
396+
);
397+
}
398+
}
399+
400+
#[test]
401+
fn network_mode_service_target_is_sanitized() {
402+
let yaml = "services:\n s:\n image: x\n network_mode: \"service:web:1\"\n";
403+
let file = parse_str(yaml).unwrap();
404+
let out = generate(&file, "p");
405+
let c = &unit_named(&out, "s.container").contents;
406+
assert!(c.contains("Network=web_1.container"), "in:\n{c}");
407+
}
408+
409+
#[test]
410+
fn volume_and_network_units_have_no_install_section() {
411+
let yaml = r#"
412+
services:
413+
s:
414+
image: x
415+
networks:
416+
net:
417+
volumes:
418+
vol:
419+
"#;
420+
let file = parse_str(yaml).unwrap();
421+
let out = generate(&file, "p");
422+
let net = &unit_named(&out, "net.network").contents;
423+
let vol = &unit_named(&out, "vol.volume").contents;
424+
assert!(
425+
!net.contains("[Install]") && !net.contains("WantedBy"),
426+
".network must carry no [Install]; got:\n{net}"
427+
);
428+
assert!(
429+
!vol.contains("[Install]") && !vol.contains("WantedBy"),
430+
".volume must carry no [Install]; got:\n{vol}"
431+
);
432+
// The container unit still carries its [Install].
433+
let c = &unit_named(&out, "s.container").contents;
434+
assert!(c.contains("[Install]") && c.contains("WantedBy=default.target"));
435+
}
436+
437+
#[test]
438+
fn privileged_maps_to_podman_arg() {
439+
let yaml = "services:\n s:\n image: x\n privileged: true\n";
440+
let file = parse_str(yaml).unwrap();
441+
let out = generate(&file, "p");
442+
let c = &unit_named(&out, "s.container").contents;
443+
assert!(c.contains("PodmanArgs=--privileged"), "in:\n{c}");
444+
assert!(
445+
!out.warnings.iter().any(|w| w.contains("privileged")),
446+
"privileged must be mapped, not warned; got: {:?}",
447+
out.warnings
448+
);
449+
}

internal/quadlet/unit/container.rs

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,15 @@ pub(crate) fn container_unit(
2121
let mut unit = Section::new("Unit");
2222
unit.add("Description", format!("{name} (podup)"));
2323
for dep in service.depends_on.service_names() {
24-
unit.add("After", format!("{dep}.service"));
24+
// The dependency's generated unit is named `{safe_unit_stem(dep)}.container`,
25+
// so its service is `{safe_unit_stem(dep)}.service`; reference that, not the
26+
// raw compose key, or the ordering would target a non-existent unit.
27+
let dep_service = format!("{}.service", safe_unit_stem(&dep));
28+
unit.add("After", dep_service.clone());
2529
if service.depends_on.required_for(&dep) {
26-
unit.add("Requires", format!("{dep}.service"));
30+
unit.add("Requires", dep_service);
2731
} else {
28-
unit.add("Wants", format!("{dep}.service"));
32+
unit.add("Wants", dep_service);
2933
}
3034
}
3135

@@ -61,6 +65,11 @@ pub(crate) fn container_unit(
6165
if service.read_only == Some(true) {
6266
container.add("ReadOnly", "true".to_string());
6367
}
68+
if service.privileged == Some(true) {
69+
// No dedicated [Container] key exists for privileged mode; pass it through
70+
// as a raw podman flag, like the other escape-hatch fields.
71+
container.add("PodmanArgs", "--privileged".to_string());
72+
}
6473
if service.init == Some(true) {
6574
container.add("RunInit", "true".to_string());
6675
}
@@ -215,13 +224,23 @@ pub(crate) fn container_unit(
215224
container.add("StopTimeout", secs.to_string());
216225
}
217226
}
218-
// `network_mode: host`/`none` map to `Network=host`/`Network=none`; other
219-
// modes (service:/container:) have no Quadlet key and are reported by
227+
// `network_mode: host`/`none` map to `Network=host`/`Network=none`.
228+
// `service:X`/`container:X` reuse another container's netns, which Quadlet
229+
// expresses as `Network={X}.container` (the `.container` special case);
230+
// other modes (bridge:, custom, …) have no key and are reported by
220231
// collect_warnings.
221232
match service.network_mode.as_deref() {
222233
Some("host") => container.add("Network", "host".to_string()),
223234
Some("none") => container.add("Network", "none".to_string()),
224-
_ => {}
235+
Some(m) => {
236+
if let Some(target) = m
237+
.strip_prefix("service:")
238+
.or_else(|| m.strip_prefix("container:"))
239+
{
240+
container.add("Network", format!("{}.container", safe_unit_stem(target)));
241+
}
242+
}
243+
None => {}
225244
}
226245
for group in &service.group_add {
227246
container.add("GroupAdd", group.clone());
@@ -284,7 +303,7 @@ pub(crate) fn container_unit(
284303
for secret in &service.secrets {
285304
container.add("Secret", render_secret(secret));
286305
}
287-
render_healthcheck(service, &mut container);
306+
render_healthcheck(name, service, &mut container, warnings);
288307

289308
let mut svc = Section::new("Service");
290309
if let Some(restart) = &service.restart {

internal/quadlet/unit/health.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@ use super::Section;
77
/// Map a compose `healthcheck:` onto the Quadlet `Health*=` keys. A disabled
88
/// healthcheck emits `HealthCmd=none`; otherwise the compose test (with any
99
/// leading `CMD`/`CMD-SHELL`/`NONE` sentinel stripped) and the timing fields
10-
/// are rendered.
11-
pub(super) fn render_healthcheck(service: &Service, container: &mut Section) {
10+
/// are rendered. Fields with no Quadlet/Podman equivalent push a warning into
11+
/// `warnings` instead of being silently dropped.
12+
pub(super) fn render_healthcheck(
13+
name: &str,
14+
service: &Service,
15+
container: &mut Section,
16+
warnings: &mut Vec<String>,
17+
) {
1218
let Some(hc) = &service.healthcheck else {
1319
return;
1420
};
@@ -43,7 +49,15 @@ pub(super) fn render_healthcheck(service: &Service, container: &mut Section) {
4349
if let Some(v) = &hc.start_period {
4450
container.add("HealthStartPeriod", v.clone());
4551
}
46-
if let Some(v) = &hc.start_interval {
47-
container.add("HealthStartupInterval", v.clone());
52+
if hc.start_interval.is_some() {
53+
// Compose `start_interval` (the probe interval during the start period)
54+
// has no Quadlet/Podman equivalent. The Quadlet `HealthStartupInterval=`
55+
// key drives Podman's separate *startup healthcheck* feature, which is a
56+
// no-op without a `HealthStartupCmd=`; Podman 5.x exposes no
57+
// `--health-start-interval`. Skip it and warn rather than emit a key that
58+
// silently does nothing.
59+
warnings.push(format!(
60+
"{name}: healthcheck.start_interval has no Quadlet/Podman equivalent and is skipped"
61+
));
4862
}
4963
}

internal/quadlet/unit/network.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,10 @@ pub(crate) fn network_unit(
5454
net.add("Label", format!("{key}={val}"));
5555
}
5656
}
57-
let mut contents = net.render();
58-
contents.push_str("\n[Install]\nWantedBy=default.target\n");
57+
// No [Install] section: the spec defines none for `.network` units, which are
58+
// pulled in automatically as dependencies of the `.container` units that use
59+
// them. Only `.container` units carry [Install].
60+
let contents = net.render();
5961
QuadletUnit {
6062
filename: format!("{}.network", safe_unit_stem(name)),
6163
contents,

internal/quadlet/unit/volume.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ pub(crate) fn volume_unit(name: &str, project: &str, config: Option<&VolumeConfi
3232
vol.add("Label", format!("{key}={val}"));
3333
}
3434
}
35-
let mut contents = vol.render();
36-
contents.push_str("\n[Install]\nWantedBy=default.target\n");
35+
// No [Install] section: the spec defines none for `.volume` units, which are
36+
// pulled in automatically as dependencies of the `.container` units that use
37+
// them. Only `.container` units carry [Install].
38+
let contents = vol.render();
3739
QuadletUnit {
3840
filename: format!("{}.volume", safe_unit_stem(name)),
3941
contents,

internal/quadlet/warnings.rs

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,26 +30,20 @@ pub(super) fn collect_warnings(name: &str, service: &Service, warnings: &mut Vec
3030
if !service.volumes_from.is_empty() {
3131
warn("volumes_from", "has no Quadlet equivalent and is skipped");
3232
}
33-
// `network_mode: host`/`none` map to `Network=`; other modes have no key.
34-
if service
35-
.network_mode
36-
.as_deref()
37-
.is_some_and(|m| m != "host" && m != "none")
38-
{
33+
// `host`/`none` map to `Network=`, and `service:X`/`container:X` map to
34+
// `Network=X.container`; only the remaining modes (bridge:, custom, …) have
35+
// no key.
36+
if service.network_mode.as_deref().is_some_and(|m| {
37+
m != "host" && m != "none" && !m.starts_with("service:") && !m.starts_with("container:")
38+
}) {
3939
warn(
4040
"network_mode",
41-
"is not mapped (only `host`/`none` are supported); use networks instead",
41+
"is not mapped (only `host`/`none`/`service:`/`container:` are supported); use networks instead",
4242
);
4343
}
4444
if !service.profiles.is_empty() {
4545
warn("profiles", "have no Quadlet equivalent and are ignored");
4646
}
47-
if service.privileged == Some(true) {
48-
warn(
49-
"privileged",
50-
"is not mapped; add PodmanArgs manually if required",
51-
);
52-
}
5347
if !service.post_start.is_empty() {
5448
warn(
5549
"post_start",
@@ -163,8 +157,7 @@ services:
163157
image: app:1.0
164158
build: .
165159
scale: 3
166-
privileged: true
167-
network_mode: "container:other"
160+
network_mode: "bridge:custom"
168161
volumes_from:
169162
- other
170163
profiles:
@@ -193,7 +186,6 @@ configs:
193186
"volumes_from",
194187
"network_mode",
195188
"profiles",
196-
"privileged",
197189
] {
198190
assert!(
199191
joined.contains(field),
@@ -205,6 +197,26 @@ configs:
205197
!joined.contains("secrets"),
206198
"secrets should be mapped, not warned; got:\n{joined}"
207199
);
200+
// privileged is now mapped to PodmanArgs=--privileged, not warned.
201+
assert!(
202+
!joined.contains("privileged"),
203+
"privileged should be mapped, not warned; got:\n{joined}"
204+
);
205+
}
206+
207+
#[test]
208+
fn service_and_container_network_modes_are_mapped_not_warned() {
209+
// `service:X`/`container:X` map to `Network=X.container`, so they must not
210+
// warn; only other unmapped modes (bridge:, custom, …) warn.
211+
for mode in ["service:db", "container:other"] {
212+
let yaml = format!("services:\n s:\n image: x\n network_mode: \"{mode}\"\n");
213+
let file = parse_str(&yaml).unwrap();
214+
let joined = generate(&file, "proj").warnings.join("\n");
215+
assert!(
216+
!joined.contains("network_mode"),
217+
"{mode} should be mapped, not warned; got:\n{joined}"
218+
);
219+
}
208220
}
209221

210222
#[test]

tests/quadlet.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,11 @@ networks:
8484

8585
assert_eq!(
8686
unit(&out, "cache.volume"),
87-
"[Volume]\nVolumeName=myproj_cache\n\n[Install]\nWantedBy=default.target\n"
87+
"[Volume]\nVolumeName=myproj_cache\n"
8888
);
8989
assert_eq!(
9090
unit(&out, "backend.network"),
91-
"[Network]\nNetworkName=myproj_backend\n\n[Install]\nWantedBy=default.target\n"
91+
"[Network]\nNetworkName=myproj_backend\n"
9292
);
9393
}
9494

0 commit comments

Comments
 (0)