From 553f30de41303a5d71f6dee70af6660e4a854a62 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Tue, 10 Mar 2026 10:56:15 -0500 Subject: [PATCH 01/12] chore: add Podman v5.6 to Podman versions Added Podman version 5.6.0, 5.6.1, and 5.6.2 to `podlet::quadlet::PodmanVersion`. Signed-off-by: Paul Nettleton --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/quadlet.rs | 9 +++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5592b67..a40f390 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -853,7 +853,7 @@ dependencies = [ [[package]] name = "podlet" -version = "0.3.2-alpha.3" +version = "0.3.2-alpha.4" dependencies = [ "clap", "color-eyre", diff --git a/Cargo.toml b/Cargo.toml index b57b95f..88c9015 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "podlet" -version = "0.3.2-alpha.3" +version = "0.3.2-alpha.4" authors = ["Paul Nettleton "] edition = "2024" rust-version = "1.85" diff --git a/src/quadlet.rs b/src/quadlet.rs index becb926..a55d846 100644 --- a/src/quadlet.rs +++ b/src/quadlet.rs @@ -613,13 +613,17 @@ pub enum PodmanVersion { V5_4, /// Podman v5.5 - #[value(name = "5.5", aliases = ["latest", "5.5.0", "5.5.1", "5.5.2"])] + #[value(name = "5.5", aliases = ["5.5.0", "5.5.1", "5.5.2"])] V5_5, + + /// Podman v5.6 + #[value(name = "5.6", aliases = ["latest", "5.6.0", "5.6.1", "5.6.2"])] + V5_6, } impl PodmanVersion { /// Latest supported version of Podman with regards to Quadlet. - pub const LATEST: Self = Self::V5_5; + pub const LATEST: Self = Self::V5_6; /// Podman version as a static string slice. pub const fn as_str(self) -> &'static str { @@ -635,6 +639,7 @@ impl PodmanVersion { Self::V5_3 => "5.3", Self::V5_4 => "5.4", Self::V5_5 => "5.5", + Self::V5_6 => "5.6", } } } From 8226845f456cf4f68e58313642dee2a13a6a0212 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Tue, 10 Mar 2026 11:00:00 -0500 Subject: [PATCH 02/12] feat(container): add `dest` as alias for `destination` mount option Signed-off-by: Paul Nettleton --- src/quadlet/container/mount.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/quadlet/container/mount.rs b/src/quadlet/container/mount.rs index 5d9e982..717351e 100644 --- a/src/quadlet/container/mount.rs +++ b/src/quadlet/container/mount.rs @@ -192,7 +192,7 @@ struct ArtifactFlat { source: String, /// Mount destination spec. - #[serde(alias = "dst", alias = "target")] + #[serde(alias = "dst", alias = "dest", alias = "target")] destination: PathBuf, /// If the artifact contains multiple blobs, the digest to use. @@ -278,6 +278,7 @@ pub struct Bind { #[serde( default, alias = "dst", + alias = "dest", alias = "target", skip_serializing_if = "Option::is_none" )] @@ -417,7 +418,7 @@ impl From for SELinuxRelabel { #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct DevPts { /// Mount destination spec. - #[serde(alias = "dst", alias = "target")] + #[serde(alias = "dst", alias = "dest", alias = "target")] pub destination: PathBuf, /// UID of the file owner. @@ -475,7 +476,7 @@ pub struct Image { pub source: String, /// Mount destination spec. - #[serde(alias = "dst", alias = "target")] + #[serde(alias = "dst", alias = "dest", alias = "target")] pub destination: PathBuf, /// Read-write permission. @@ -501,7 +502,7 @@ pub struct Volume { pub source: Option, /// Mount destination spec. - #[serde(alias = "dst", alias = "target")] + #[serde(alias = "dst", alias = "dest", alias = "target")] pub destination: PathBuf, /// Only read permissions From c18bbaa6069fbdf26488b247fedd82ec19463a19 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Tue, 10 Mar 2026 11:13:27 -0500 Subject: [PATCH 03/12] feat(container): add `name` artifact mount option Signed-off-by: Paul Nettleton --- src/quadlet/container.rs | 20 ++++++++++++++++++++ src/quadlet/container/mount.rs | 22 ++++++++++++++++++---- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/quadlet/container.rs b/src/quadlet/container.rs index c34afb9..625e0e4 100644 --- a/src/quadlet/container.rs +++ b/src/quadlet/container.rs @@ -20,6 +20,7 @@ use smart_default::SmartDefault; use crate::serde::{quadlet::seq_quote_whitespace, serialize_display_seq, skip_true}; +use self::mount::Artifact; pub use self::{device::Device, mount::Mount, rootfs::Rootfs, volume::Volume}; use super::{AutoUpdate, Downgrade, DowngradeError, HostPaths, PodmanVersion, push_arg_display}; @@ -315,6 +316,10 @@ pub struct Container { impl Downgrade for Container { fn downgrade(&mut self, version: PodmanVersion) -> Result<(), DowngradeError> { + if version < PodmanVersion::V5_6 { + self.remove_v5_6_options()?; + } + if version < PodmanVersion::V5_5 { self.remove_v5_5_options()?; } @@ -369,6 +374,21 @@ macro_rules! extract { } impl Container { + /// Remove Quadlet options added in Podman v5.6.0 + fn remove_v5_6_options(&mut self) -> Result<(), DowngradeError> { + self.mount.iter().try_for_each(|mount| { + if let Mount::Artifact(Artifact { name: Some(_), .. }) = mount { + Err(DowngradeError::Option { + quadlet_option: "Mount", + value: mount.to_string(), + supported_version: PodmanVersion::V5_6, + }) + } else { + Ok(()) + } + }) + } + /// Remove Quadlet options added in Podman v5.5.0 fn remove_v5_5_options(&mut self) -> Result<(), DowngradeError> { self.mount.iter().try_for_each(|mount| { diff --git a/src/quadlet/container/mount.rs b/src/quadlet/container/mount.rs index 717351e..a02db11 100644 --- a/src/quadlet/container/mount.rs +++ b/src/quadlet/container/mount.rs @@ -181,6 +181,9 @@ pub struct Artifact { /// If the artifact contains multiple blobs, the digest or title of the blob to use. pub digest_or_title: Option, + + /// Overwrite the filename used inside the container for mounting. + pub name: Option, } /// A flattened version of [`Artifact`] for (de)serialization. @@ -202,6 +205,10 @@ struct ArtifactFlat { /// If the artifact contains multiple blobs, the title to use. #[serde(default, skip_serializing_if = "Option::is_none")] title: Option, + + /// Overwrite the filename used inside the container for mounting. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, } impl From for ArtifactFlat { @@ -210,6 +217,7 @@ impl From for ArtifactFlat { source, destination, digest_or_title, + name, }: Artifact, ) -> Self { let (digest, title) = match digest_or_title { @@ -223,6 +231,7 @@ impl From for ArtifactFlat { destination, digest, title, + name, } } } @@ -236,6 +245,7 @@ impl TryFrom for Artifact { destination, digest, title, + name, }: ArtifactFlat, ) -> Result { let digest_or_title = match (digest, title) { @@ -249,6 +259,7 @@ impl TryFrom for Artifact { source, destination, digest_or_title, + name, }) } } @@ -557,13 +568,14 @@ mod tests { Mount::Artifact(Artifact { source: "artifact".to_owned(), destination: "/dst".into(), - digest_or_title: None + digest_or_title: None, + name: None, }) ); assert_eq!(mount.to_string(), string); // Digest - let string = "type=artifact,source=artifact,destination=/dst,digest=digest"; + let string = "type=artifact,source=artifact,destination=/dst,digest=digest,name=name"; let mount: Mount = string.parse()?; assert_eq!( mount, @@ -571,12 +583,13 @@ mod tests { source: "artifact".to_owned(), destination: "/dst".into(), digest_or_title: Some(DigestOrTitle::Digest("digest".to_owned())), + name: Some("name".to_owned()), }) ); assert_eq!(mount.to_string(), string); // Title - let string = "type=artifact,source=artifact,destination=/dst,title=title"; + let string = "type=artifact,source=artifact,destination=/dst,title=title,name=name"; let mount: Mount = string.parse()?; assert_eq!( mount, @@ -584,6 +597,7 @@ mod tests { source: "artifact".to_owned(), destination: "/dst".into(), digest_or_title: Some(DigestOrTitle::Title("title".to_owned())), + name: Some("name".to_owned()), }) ); assert_eq!(mount.to_string(), string); @@ -591,7 +605,7 @@ mod tests { // Digest and title is error assert!( Mount::from_str( - "type=artifact,source=artifact,destination=/dst,digest=digest,title=title" + "type=artifact,source=artifact,destination=/dst,digest=digest,title=title,name=name" ) .is_err() ); From dbc3a2fe0f9f9859f089761b39803dce44f1c961 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Tue, 10 Mar 2026 11:27:52 -0500 Subject: [PATCH 04/12] feat(pod): add `Label=` Quadlet option Signed-off-by: Paul Nettleton --- src/cli/pod.rs | 16 ++++++++++------ src/quadlet/pod.rs | 12 ++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/cli/pod.rs b/src/cli/pod.rs index bd4feb0..45def63 100644 --- a/src/cli/pod.rs +++ b/src/cli/pod.rs @@ -135,6 +135,14 @@ pub struct Create { #[arg(long, value_name = "IPV6")] ip6: Option, + /// Add metadata to the pod. + /// + /// Converts to "Label=KEY=VALUE". + /// + /// Can be specified multiple times. + #[arg(short, long, value_name = "KEY=VALUE")] + label: Vec, + /// Specify a custom network for the pod. /// /// Converts to "Network=MODE". @@ -257,6 +265,7 @@ impl From for quadlet::Pod { hostname, ip, ip6, + label, network, network_alias, name_flag: pod_name, @@ -283,6 +292,7 @@ impl From for quadlet::Pod { host_name: hostname, ip, ip6, + label, network, network_alias, podman_args: (!podman_args.is_empty()).then_some(podman_args), @@ -391,12 +401,6 @@ struct PodmanArgs { #[arg(long, value_name = "NAME")] infra_name: Option, - /// Add metadata to the pod. - /// - /// Can be specified multiple times - #[arg(short, long, value_name = "KEY=VALUE")] - label: Vec, - /// Read in a line-delimited file of labels #[arg(long, value_name = "FILE")] label_file: Option, diff --git a/src/quadlet/pod.rs b/src/quadlet/pod.rs index 7359db5..dd47ef5 100644 --- a/src/quadlet/pod.rs +++ b/src/quadlet/pod.rs @@ -5,6 +5,8 @@ use std::{ use serde::Serialize; +use crate::serde::quadlet::seq_quote_whitespace; + use super::{ Downgrade, DowngradeError, HostPaths, PodmanVersion, ResourceKind, container::{Dns, Volume}, @@ -45,6 +47,10 @@ pub struct Pod { #[serde(rename = "IP6")] pub ip6: Option, + /// Set one or more OCI labels on the pod. + #[serde(serialize_with = "seq_quote_whitespace")] + pub label: Vec, + /// Specify a custom network for the pod. pub network: Vec, @@ -95,6 +101,12 @@ impl HostPaths for Pod { impl Downgrade for Pod { fn downgrade(&mut self, version: PodmanVersion) -> Result<(), DowngradeError> { + if version < PodmanVersion::V5_6 { + for label in std::mem::take(&mut self.label) { + self.push_arg("label", &label); + } + } + if version < PodmanVersion::V5_5 { if let Some(host_name) = self.host_name.take() { self.push_arg("hostname", &host_name); From 40283e37f12ef2f014d254e6b9538784a46d6806 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Tue, 10 Mar 2026 11:51:59 -0500 Subject: [PATCH 05/12] feat(pod): add `ExitPolicy=` Quadlet option Signed-off-by: Paul Nettleton --- src/cli/pod.rs | 40 ++++++++++++---------------------------- src/quadlet.rs | 2 +- src/quadlet/pod.rs | 39 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/src/cli/pod.rs b/src/cli/pod.rs index 45def63..511a6bd 100644 --- a/src/cli/pod.rs +++ b/src/cli/pod.rs @@ -5,14 +5,14 @@ use std::{ path::PathBuf, }; -use clap::{ArgAction, Args, Subcommand, ValueEnum}; +use clap::{ArgAction, Args, Subcommand}; use compose_spec::service::blkio_config::Weight; use serde::Serialize; use smart_default::SmartDefault; use crate::{ quadlet::{ - self, + self, ExitPolicy, container::{Device, DnsEntry, Volume}, }, serde::skip_true, @@ -105,6 +105,14 @@ pub struct Create { #[arg(long, value_name = "DOMAIN")] dns_search: Vec, + /// Set the exit policy of the pod when the last container exits. + /// + /// Converts to "ExitPolicy=EXIT_POLICY". + /// + /// Default for Quadlets is `stop`. + #[arg(long, value_enum, default_value_t)] + exit_policy: ExitPolicy, + /// GID map for the user namespace. /// /// Converts to "GIDMap=POD_GID:HOST_GID[:AMOUNT]". @@ -261,6 +269,7 @@ impl From for quadlet::Pod { dns, dns_option, dns_search, + exit_policy, gidmap, hostname, ip, @@ -288,6 +297,7 @@ impl From for quadlet::Pod { dns: dns.into(), dns_option, dns_search, + exit_policy, gid_map: gidmap, host_name: hostname, ip, @@ -361,13 +371,6 @@ struct PodmanArgs { #[arg(long, value_name = "PATH:RATE")] device_write_bps: Vec, - /// Set the exit policy of the pod when the last container exits. - /// - /// Only `stop` is supported as it is automatically set by Quadlet. - #[arg(long, value_enum, default_value_t)] - #[serde(skip)] - exit_policy: ExitPolicy, - /// GPU devices to add to the pod (`all` to pass all GPUs). /// /// Can be specified multiple times. @@ -486,25 +489,6 @@ impl Display for PodmanArgs { } } -/// Supported values of `podman pod create --exit-policy` for [`PodmanArgs`]. -/// -/// Only [`Stop`](Self::Stop) is supported because it automatically set by Quadlet for `.pod` files. -#[derive(ValueEnum, Debug, Default, Clone, Copy, PartialEq, Eq)] -enum ExitPolicy { - /// The pod (including its infra container) is stopped when the last container exits. - #[default] - Stop, -} - -impl Display for ExitPolicy { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - let str = match self { - Self::Stop => "stop", - }; - f.write_str(str) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/quadlet.rs b/src/quadlet.rs index a55d846..7f16ff5 100644 --- a/src/quadlet.rs +++ b/src/quadlet.rs @@ -32,7 +32,7 @@ pub use self::{ install::Install, kube::Kube, network::{IpRange, Network}, - pod::Pod, + pod::{ExitPolicy, Pod}, service::Service, unit::Unit, volume::Volume, diff --git a/src/quadlet/pod.rs b/src/quadlet/pod.rs index dd47ef5..3ab1722 100644 --- a/src/quadlet/pod.rs +++ b/src/quadlet/pod.rs @@ -1,11 +1,13 @@ use std::{ + fmt::{self, Display, Formatter}, net::{Ipv4Addr, Ipv6Addr}, path::PathBuf, }; +use clap::ValueEnum; use serde::Serialize; -use crate::serde::quadlet::seq_quote_whitespace; +use crate::serde::{quadlet::seq_quote_whitespace, skip_default}; use super::{ Downgrade, DowngradeError, HostPaths, PodmanVersion, ResourceKind, @@ -32,6 +34,10 @@ pub struct Pod { #[serde(rename = "DNSSearch")] pub dns_search: Vec, + /// Set the exit policy of the pod when the last container exits. + #[serde(skip_serializing_if = "skip_default")] + pub exit_policy: ExitPolicy, + /// GID map for the user namespace. #[serde(rename = "GIDMap")] pub gid_map: Vec, @@ -105,6 +111,14 @@ impl Downgrade for Pod { for label in std::mem::take(&mut self.label) { self.push_arg("label", &label); } + + if self.exit_policy != ExitPolicy::default() { + return Err(DowngradeError::Option { + quadlet_option: "ExitPolicy", + value: std::mem::take(&mut self.exit_policy).to_string(), + supported_version: PodmanVersion::V5_6, + }); + } } if version < PodmanVersion::V5_5 { @@ -199,3 +213,26 @@ impl Pod { push_arg(podman_args, flag, arg); } } + +/// Supported values of the `ExitPolicy=` Quadlet option for [`Pod`] units. +#[derive(ValueEnum, Serialize, Debug, Default, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ExitPolicy { + /// The pod continues running, by keeping its infra container alive, when the last container + /// exits. + Continue, + + /// The pod (including its infra container) is stopped when the last container exits. + #[default] + Stop, +} + +impl Display for ExitPolicy { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + let str = match self { + Self::Continue => "continue", + Self::Stop => "stop", + }; + f.write_str(str) + } +} From 26579da4eaf49de9ff60aecba95f157aa13a26fc Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Wed, 11 Mar 2026 13:13:03 -0500 Subject: [PATCH 06/12] feat(volume): add `podman volume create --uid` option Signed-off-by: Paul Nettleton --- src/cli/generate.rs | 6 ++++++ src/cli/volume.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/cli/generate.rs b/src/cli/generate.rs index f2841c9..89bfca8 100644 --- a/src/cli/generate.rs +++ b/src/cli/generate.rs @@ -662,6 +662,10 @@ struct VolumeInspect { /// --opt options: IndexMap, + + /// --uid + #[serde(rename = "UID", default)] + uid: Option, } impl VolumeInspect { @@ -706,6 +710,7 @@ impl From for Volume { driver, labels, options, + uid, }: VolumeInspect, ) -> Self { Volume::Create { @@ -721,6 +726,7 @@ impl From for Volume { .into_iter() .map(|(label, value)| format!("{label}={value}")) .collect(), + podman_args: volume::PodmanArgs { uid }, name, }, } diff --git a/src/cli/volume.rs b/src/cli/volume.rs index b0b43d2..114a989 100644 --- a/src/cli/volume.rs +++ b/src/cli/volume.rs @@ -1,6 +1,7 @@ mod opt; use clap::{Args, Subcommand}; +use serde::Serialize; pub use self::opt::Opt; @@ -73,6 +74,12 @@ pub struct Create { #[arg(short, long, value_name = "KEY=VALUE")] pub label: Vec, + /// Arguments that do not have a more specific Quadlet option. + /// + /// Converts to "PodmanArgs=ARGS". + #[command(flatten)] + pub podman_args: PodmanArgs, + /// The name of the volume to create /// /// This will be used as the name of the generated file when used with @@ -86,13 +93,39 @@ impl From for crate::quadlet::Volume { driver, opt, label, + podman_args, name: _, }: Create, ) -> Self { + let podman_args = + crate::serde::args::to_string(podman_args).expect("PodmanArgs serializes to args"); + Self { driver, label, + podman_args: (!podman_args.is_empty()).then_some(podman_args), ..opt.into() } } } + +/// [`Args`] for `podman volume create` (i.e. [`Create`]) that convert into `PodmanArgs=ARGS`. +#[derive(Args, Serialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct PodmanArgs { + /// Set the UID that the volume will be created as. + #[arg(long)] + pub uid: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn podman_args_default_serialize_empty() -> Result<(), crate::serde::args::Error> { + let args = crate::serde::args::to_string(PodmanArgs::default())?; + assert!(args.is_empty()); + Ok(()) + } +} From 750768585de34dfe176616fb18779af3a8f93e92 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Wed, 11 Mar 2026 13:19:04 -0500 Subject: [PATCH 07/12] feat(volume): add `podman volume create --gid` option Signed-off-by: Paul Nettleton --- src/cli/generate.rs | 7 ++++++- src/cli/volume.rs | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/cli/generate.rs b/src/cli/generate.rs index 89bfca8..2ddbcb5 100644 --- a/src/cli/generate.rs +++ b/src/cli/generate.rs @@ -666,6 +666,10 @@ struct VolumeInspect { /// --uid #[serde(rename = "UID", default)] uid: Option, + + /// --gid + #[serde(rename = "GID", default)] + gid: Option, } impl VolumeInspect { @@ -711,6 +715,7 @@ impl From for Volume { labels, options, uid, + gid, }: VolumeInspect, ) -> Self { Volume::Create { @@ -726,7 +731,7 @@ impl From for Volume { .into_iter() .map(|(label, value)| format!("{label}={value}")) .collect(), - podman_args: volume::PodmanArgs { uid }, + podman_args: volume::PodmanArgs { uid, gid }, name, }, } diff --git a/src/cli/volume.rs b/src/cli/volume.rs index 114a989..db961c7 100644 --- a/src/cli/volume.rs +++ b/src/cli/volume.rs @@ -116,6 +116,10 @@ pub struct PodmanArgs { /// Set the UID that the volume will be created as. #[arg(long)] pub uid: Option, + + /// Set the GID that the volume will be created as. + #[arg(long)] + pub gid: Option, } #[cfg(test)] From 6a23169526001265ab3ef4db1183410aabaffbca Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Wed, 11 Mar 2026 13:39:08 -0500 Subject: [PATCH 08/12] feat(image): add `Policy=` Quadlet option Signed-off-by: Paul Nettleton --- src/cli/image.rs | 12 +++++++++++- src/quadlet/image.rs | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/cli/image.rs b/src/cli/image.rs index bcf45f4..adf7a3c 100644 --- a/src/cli/image.rs +++ b/src/cli/image.rs @@ -3,7 +3,7 @@ use std::{path::PathBuf, str::FromStr}; use clap::{Args, Subcommand}; use thiserror::Error; -use crate::quadlet::{self, image::DecryptionKey}; +use crate::quadlet::{self, container::PullPolicy, image::DecryptionKey}; use super::image_to_name; @@ -107,6 +107,14 @@ pub struct Pull { #[arg(long, conflicts_with_all = ["os", "arch"], value_name = "OS/ARCH")] pub platform: Option, + /// Pull image policy. + /// + /// Converts to "Policy=POLICY". + /// + /// Default is `always`. + #[arg(long)] + pub policy: Option, + /// Number of times to retry pulling or pushing images between the registry and local storage. /// /// Converts to "Retry=ATTEMPTS". @@ -154,6 +162,7 @@ impl From for quadlet::Image { disable_content_trust: _, os, platform, + policy, retry, retry_delay, tls_verify, @@ -176,6 +185,7 @@ impl From for quadlet::Image { image_tag: None, os, podman_args: None, + policy, retry, retry_delay, tls_verify, diff --git a/src/quadlet/image.rs b/src/quadlet/image.rs index 5cad27c..18e7fc5 100644 --- a/src/quadlet/image.rs +++ b/src/quadlet/image.rs @@ -9,7 +9,8 @@ use std::{ use serde::{Serialize, Serializer}; use super::{ - Downgrade, DowngradeError, HostPaths, PodmanVersion, ResourceKind, push_arg, push_arg_display, + Downgrade, DowngradeError, HostPaths, PodmanVersion, ResourceKind, container::PullPolicy, + push_arg, push_arg_display, }; #[derive(Serialize, Debug, Clone, PartialEq)] @@ -51,6 +52,9 @@ pub struct Image { /// generated file. pub podman_args: Option, + /// The pull policy to use when pulling the image. + pub policy: Option, + /// Number of times to retry the image pull when a HTTP error occurs. pub retry: Option, @@ -82,6 +86,16 @@ impl HostPaths for Image { impl Downgrade for Image { #[allow(clippy::unused_self)] fn downgrade(&mut self, version: PodmanVersion) -> Result<(), DowngradeError> { + if version < PodmanVersion::V5_6 { + if let Some(policy) = self.policy.take() { + return Err(DowngradeError::Option { + quadlet_option: "Policy", + value: policy.to_string(), + supported_version: PodmanVersion::V5_6, + }); + } + } + if version < PodmanVersion::V5_5 { if let Some(retry) = self.retry.take() { // `podman image pull --retry` was added in Podman v5.0.0 From b4de4905f6985f74e3820b5a439b62d8786624de Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Wed, 11 Mar 2026 13:49:13 -0500 Subject: [PATCH 09/12] feat(network): add `InterfaceName=` Quadlet option Signed-off-by: Paul Nettleton --- src/cli/generate.rs | 2 +- src/cli/network.rs | 55 +++++++++++++++++++++++++++++------------- src/quadlet/network.rs | 9 +++++++ 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/src/cli/generate.rs b/src/cli/generate.rs index 2ddbcb5..4310fce 100644 --- a/src/cli/generate.rs +++ b/src/cli/generate.rs @@ -624,6 +624,7 @@ impl From for Network { .collect(), driver: Some(driver), gateway, + interface_name: Some(network_interface), internal, ipam_driver: Some(ipam_driver), ip_range, @@ -638,7 +639,6 @@ impl From for Network { .collect(), subnet, podman_args: network::PodmanArgs { - interface_name: Some(network_interface), route: routes.iter().map(NetworkRoute::to_route_value).collect(), }, name, diff --git a/src/cli/network.rs b/src/cli/network.rs index 886dde5..8337363 100644 --- a/src/cli/network.rs +++ b/src/cli/network.rs @@ -75,6 +75,12 @@ pub struct Create { #[arg(long)] pub gateway: Vec, + /// Maps to the `network_interface` option in the network config + /// + /// Converts to "InterfaceName=NAME" + #[arg(long, value_name = "NAME")] + pub interface_name: Option, + /// Restrict external access of the network /// /// Converts to "Internal=true" @@ -138,21 +144,40 @@ pub struct Create { } impl From for crate::quadlet::Network { - fn from(value: Create) -> Self { - let podman_args = value.podman_args.to_string(); + fn from( + Create { + disable_dns, + dns, + driver, + gateway, + interface_name, + internal, + ipam_driver, + ip_range, + ipv6, + label, + opt, + subnet, + podman_args, + name: _, + }: Create, + ) -> Self { + let podman_args = podman_args.to_string(); + Self { - disable_dns: value.disable_dns, - dns: value.dns, - driver: value.driver, - gateway: value.gateway, - internal: value.internal, - ipam_driver: value.ipam_driver, - ip_range: value.ip_range, - ipv6: value.ipv6, - label: value.label, - options: value.opt, + disable_dns, + dns, + driver, + gateway, + interface_name, + internal, + ipam_driver, + ip_range, + ipv6, + label, + options: opt, podman_args: (!podman_args.is_empty()).then_some(podman_args), - subnet: value.subnet, + subnet, } } } @@ -160,10 +185,6 @@ impl From for crate::quadlet::Network { #[derive(Args, Serialize, Debug, Default, Clone, PartialEq)] #[serde(rename_all = "kebab-case")] pub struct PodmanArgs { - /// Maps to the `network_interface` option in the network config - #[arg(long, value_name = "NAME")] - pub interface_name: Option, - /// A static route to add to every container in this network /// /// Can be specified multiple times diff --git a/src/quadlet/network.rs b/src/quadlet/network.rs index 0cfe1c7..a44e26b 100644 --- a/src/quadlet/network.rs +++ b/src/quadlet/network.rs @@ -31,6 +31,9 @@ pub struct Network { /// Define a gateway for the subnet. pub gateway: Vec, + /// Maps to the `network_interface` option in the network config. + pub interface_name: Option, + /// Restrict external access of this network. #[serde(skip_serializing_if = "Not::not")] pub internal: bool, @@ -72,6 +75,12 @@ impl Network { impl Downgrade for Network { fn downgrade(&mut self, version: PodmanVersion) -> Result<(), DowngradeError> { + if version < PodmanVersion::V5_6 { + if let Some(interface_name) = self.interface_name.take() { + self.push_arg("interface-name", &interface_name); + } + } + if version < PodmanVersion::V4_7 { for dns in std::mem::take(&mut self.dns) { self.push_arg("dns", &dns); From 056cde822a61d0fa17e6b2ef6f976c4c1a69e593 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Thu, 12 Mar 2026 00:29:20 -0500 Subject: [PATCH 10/12] feat(compose): support `pids_limit` when converting to k8s Set the "io.podman.annotations.pids-limit/{container_name}" annotation in the pod metadata. Signed-off-by: Paul Nettleton --- src/cli/k8s.rs | 33 ++++++++++----------------------- src/cli/k8s/service.rs | 24 ++++++++++++++++-------- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/src/cli/k8s.rs b/src/cli/k8s.rs index 7bd19ff..8a7084a 100644 --- a/src/cli/k8s.rs +++ b/src/cli/k8s.rs @@ -6,10 +6,7 @@ mod volume; use color_eyre::eyre::{OptionExt, WrapErr, ensure}; use compose_spec::{Compose, Resource}; -use k8s_openapi::{ - api::core::v1::{PersistentVolumeClaim, Pod, PodSpec}, - apimachinery::pkg::apis::meta::v1::ObjectMeta, -}; +use k8s_openapi::api::core::v1::{PersistentVolumeClaim, Pod}; use self::service::Service; @@ -87,26 +84,16 @@ impl TryFrom for File { let name = name.map(String::from).ok_or_eyre("`name` is required")?; - let spec = - services - .into_iter() - .try_fold(PodSpec::default(), |mut spec, (name, service)| { - Service::from_compose(&name, service) - .add_to_pod_spec(&mut spec) - .wrap_err_with(|| { - format!("error adding service `{name}` to Kubernetes pod spec") - }) - .map(|()| spec) - })?; + let mut pod = Pod::default(); + pod.metadata.name = Some(name.clone()); - let pod = Pod { - metadata: ObjectMeta { - name: Some(name.clone()), - ..ObjectMeta::default() - }, - spec: Some(spec), - status: None, - }; + for (name, service) in services { + Service::from_compose(&name, service) + .add_to_pod(&mut pod) + .wrap_err_with(|| { + format!("error adding service `{name}` to Kubernetes pod spec") + })?; + } let persistent_volume_claims = volumes .into_iter() diff --git a/src/cli/k8s/service.rs b/src/cli/k8s/service.rs index 6b85ecc..09e30a2 100644 --- a/src/cli/k8s/service.rs +++ b/src/cli/k8s/service.rs @@ -25,7 +25,7 @@ use compose_spec::{ use indexmap::{IndexMap, IndexSet}; use k8s_openapi::{ api::core::v1::{ - Capabilities, Container, ContainerPort, EnvVar, ExecAction, PodSpec, Probe, + Capabilities, Container, ContainerPort, EnvVar, ExecAction, Pod, Probe, ResourceRequirements, SELinuxOptions, SecurityContext, }, apimachinery::pkg::api::resource::Quantity, @@ -51,6 +51,7 @@ pub(super) struct Service { environment: ListOrMap, healthcheck: Option, image: Option, + pids_limit: Option>, ports: Ports, pull_policy: Option, stdin_open: bool, @@ -199,7 +200,6 @@ impl Service { oom_kill_disable, oom_score_adj, pid, - pids_limit, platform, profiles, restart, @@ -235,6 +235,7 @@ impl Service { environment, healthcheck, image, + pids_limit, ports, pull_policy, stdin_open, @@ -245,12 +246,12 @@ impl Service { } } - /// Add the service to a [`PodSpec`]'s [`Container`]s and [`Volume`]s. + /// Add the service to a [`Pod`]'s [`Container`]s and [`Volume`]s. /// /// # Errors /// /// Returns an error if an unsupported option was used or conversion of one of the fields fails. - pub(super) fn add_to_pod_spec(self, spec: &mut PodSpec) -> color_eyre::Result<()> { + pub(super) fn add_to_pod(self, pod: &mut Pod) -> color_eyre::Result<()> { let Self { unsupported, name, @@ -261,6 +262,7 @@ impl Service { environment, healthcheck, image, + pids_limit, ports, pull_policy, stdin_open, @@ -272,13 +274,15 @@ impl Service { unsupported.ensure_empty()?; + let spec = pod.spec.get_or_insert_default(); + let volume_mounts = tmpfs_and_volumes_try_into_volume_mounts(tmpfs, volumes, &name, &mut spec.volumes) // converting `tmpfs` always succeeds .wrap_err("error converting `volumes`")?; spec.containers.push(Container { - name: name.into(), + name: name.clone().into(), resources: resources.into_resource_requirements(), security_context: security_context.try_into_security_context()?, args: command @@ -348,6 +352,13 @@ impl Service { ..Container::default() }); + if let Some(pids_limit) = pids_limit { + pod.metadata.annotations.get_or_insert_default().insert( + format!("io.podman.annotations.pids-limit/{name}"), + pids_limit.to_string(), + ); + } + Ok(()) } } @@ -724,7 +735,6 @@ struct Unsupported { oom_kill_disable: bool, oom_score_adj: Option, pid: Option, - pids_limit: Option>, platform: Option, profiles: IndexSet, restart: Option, @@ -797,7 +807,6 @@ impl Unsupported { oom_kill_disable, oom_score_adj, pid, - pids_limit, platform, profiles, restart, @@ -858,7 +867,6 @@ impl Unsupported { ("memswap_limit", memswap_limit.is_none()), ("oom_kill_disable", !oom_kill_disable), ("oom_score_adj", oom_score_adj.is_none()), - ("pids_limit", pids_limit.is_none()), ("platform", platform.is_none()), ("profiles", profiles.is_empty()), ("runtime", runtime.is_none()), From 6944438143b2c0bb388f434173ac53c01c4ebe39 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Thu, 12 Mar 2026 00:51:28 -0500 Subject: [PATCH 11/12] feat(compose): support `cpuset` when converting to k8s Set the "io.podman.annotations.cpuset/{container_name}" annotation in the pod metadata. Signed-off-by: Paul Nettleton --- src/cli/k8s/service.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/cli/k8s/service.rs b/src/cli/k8s/service.rs index 09e30a2..26abee0 100644 --- a/src/cli/k8s/service.rs +++ b/src/cli/k8s/service.rs @@ -47,6 +47,7 @@ pub(super) struct Service { resources: ContainerResources, security_context: ContainerSecurityContext, command: Option, + cpuset: CpuSet, entrypoint: Option, environment: ListOrMap, healthcheck: Option, @@ -164,7 +165,6 @@ impl Service { cpu_quota, cpu_rt_runtime, cpu_rt_period, - cpuset, cgroup, cgroup_parent, configs, @@ -231,6 +231,7 @@ impl Service { user, }, command, + cpuset, entrypoint, environment, healthcheck, @@ -251,6 +252,7 @@ impl Service { /// # Errors /// /// Returns an error if an unsupported option was used or conversion of one of the fields fails. + #[expect(clippy::too_many_lines, reason = "`Self` expansion")] pub(super) fn add_to_pod(self, pod: &mut Pod) -> color_eyre::Result<()> { let Self { unsupported, @@ -258,6 +260,7 @@ impl Service { resources, security_context, command, + cpuset, entrypoint, environment, healthcheck, @@ -359,6 +362,13 @@ impl Service { ); } + if !cpuset.is_empty() { + pod.metadata.annotations.get_or_insert_default().insert( + format!("io.podman.annotations.cpuset/{name}"), + cpuset.to_string(), + ); + } + Ok(()) } } @@ -699,7 +709,6 @@ struct Unsupported { cpu_quota: Option, cpu_rt_runtime: Option, cpu_rt_period: Option, - cpuset: CpuSet, cgroup: Option, cgroup_parent: Option, configs: Vec>, @@ -771,7 +780,6 @@ impl Unsupported { cpu_quota, cpu_rt_runtime, cpu_rt_period, - cpuset, cgroup, cgroup_parent, configs, @@ -835,7 +843,6 @@ impl Unsupported { ("cpu_quota", cpu_quota.is_none()), ("cpu_rt_runtime", cpu_rt_runtime.is_none()), ("cpu_rt_period", cpu_rt_period.is_none()), - ("cpuset", cpuset.is_empty()), ("cgroup", cgroup.is_none()), ("cgroup_parent", cgroup_parent.is_none()), ("configs", configs.is_empty()), From ee117fd6ef8ef9c4ddd11047840ba571c00f9340 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Thu, 12 Mar 2026 01:05:09 -0500 Subject: [PATCH 12/12] feat(compose): support `stop_signal` when converting to k8s Sets the container's `lifecycle.stopSignal` field. Signed-off-by: Paul Nettleton --- src/cli/k8s/service.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/cli/k8s/service.rs b/src/cli/k8s/service.rs index 26abee0..3da0c45 100644 --- a/src/cli/k8s/service.rs +++ b/src/cli/k8s/service.rs @@ -25,7 +25,7 @@ use compose_spec::{ use indexmap::{IndexMap, IndexSet}; use k8s_openapi::{ api::core::v1::{ - Capabilities, Container, ContainerPort, EnvVar, ExecAction, Pod, Probe, + Capabilities, Container, ContainerPort, EnvVar, ExecAction, Lifecycle, Pod, Probe, ResourceRequirements, SELinuxOptions, SecurityContext, }, apimachinery::pkg::api::resource::Quantity, @@ -56,6 +56,7 @@ pub(super) struct Service { ports: Ports, pull_policy: Option, stdin_open: bool, + stop_signal: Option, tmpfs: Option>, tty: bool, volumes: Volumes, @@ -208,7 +209,6 @@ impl Service { secrets, shm_size, stop_grace_period, - stop_signal, storage_opt, sysctls, ulimits, @@ -240,6 +240,7 @@ impl Service { ports, pull_policy, stdin_open, + stop_signal, tmpfs, tty, volumes, @@ -269,6 +270,7 @@ impl Service { ports, pull_policy, stdin_open, + stop_signal, tmpfs, tty, volumes, @@ -313,6 +315,11 @@ impl Service { }) .transpose() .wrap_err("error converting `environment`")?, + lifecycle: stop_signal.map(|stop_signal| Lifecycle { + post_start: None, + pre_stop: None, + stop_signal: Some(stop_signal), + }), liveness_probe: healthcheck .and_then(|healthcheck| match healthcheck { Healthcheck::Command(command) => { @@ -752,7 +759,6 @@ struct Unsupported { secrets: Vec>, shm_size: Option, stop_grace_period: Option, - stop_signal: Option, storage_opt: Map, sysctls: ListOrMap, ulimits: Ulimits, @@ -823,7 +829,6 @@ impl Unsupported { secrets, shm_size, stop_grace_period, - stop_signal, storage_opt, sysctls, ulimits, @@ -880,7 +885,6 @@ impl Unsupported { ("scale", scale.is_none()), ("secrets", secrets.is_empty()), ("shm_size", shm_size.is_none()), - ("stop_signal", stop_signal.is_none()), ("storage_opt", storage_opt.is_empty()), ("ulimits", ulimits.is_empty()), ("userns_mode", userns_mode.is_none()),