From e04a935a27ac2eb7c2b5ddf4306e52974e69d053 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Mon, 23 Mar 2026 11:54:25 -0500 Subject: [PATCH 1/4] chore: add Podman v5.8 to Podman versions Added Podman version 5.8.0 and 5.8.1 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 b3fe49a..0aa3306 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -853,7 +853,7 @@ dependencies = [ [[package]] name = "podlet" -version = "0.3.2-alpha.5" +version = "0.3.2-alpha.6" dependencies = [ "clap", "color-eyre", diff --git a/Cargo.toml b/Cargo.toml index c9d145f..7de5e59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "podlet" -version = "0.3.2-alpha.5" +version = "0.3.2-alpha.6" authors = ["Paul Nettleton "] edition = "2024" rust-version = "1.85" diff --git a/src/quadlet.rs b/src/quadlet.rs index 756db3b..a4c675b 100644 --- a/src/quadlet.rs +++ b/src/quadlet.rs @@ -645,13 +645,17 @@ pub enum PodmanVersion { V5_6, /// Podman v5.7 - #[value(name = "5.7", aliases = ["latest", "5.7.0", "5.7.1"])] + #[value(name = "5.7", aliases = ["5.7.0", "5.7.1"])] V5_7, + + /// Podman v5.8 + #[value(name = "5.8", aliases = ["latest", "5.8.0", "5.8.1"])] + V5_8, } impl PodmanVersion { /// Latest supported version of Podman with regards to Quadlet. - pub const LATEST: Self = Self::V5_7; + pub const LATEST: Self = Self::V5_8; /// Podman version as a static string slice. pub const fn as_str(self) -> &'static str { @@ -669,6 +673,7 @@ impl PodmanVersion { Self::V5_5 => "5.5", Self::V5_6 => "5.6", Self::V5_7 => "5.7", + Self::V5_8 => "5.8", } } } From 53d619482bfbb24eb1320efdade975231e57c1f4 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Sat, 28 Mar 2026 15:58:13 -0500 Subject: [PATCH 2/4] feat: change stdout output to `.quadlets` file format Changed the comment at the beginning of each file, e.g. from `# test.container` to `# FileName=test`. Signed-off-by: Paul Nettleton --- src/cli.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 646b8bf..fe586b4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -105,7 +105,7 @@ pub struct Cli { /// For example, `podlet podman run -e ONE=one -e TWO=two image` results in: /// /// ```ini - /// # image.container + /// # FileName=image /// [Container] /// Environment=ONE=one TWO=two /// Image=image @@ -114,7 +114,7 @@ pub struct Cli { /// While `podlet -s Environment podman run -e ONE=one -e TWO=two image` results in: /// /// ```ini - /// # image.container + /// # FileName=image /// [Container] /// Environment=ONE=one /// Environment=TWO=two @@ -227,14 +227,14 @@ By default, Podlet will combine all Quadlet options that can be joined into a si For example, `podlet podman run -e ONE=one -e TWO=two image` results in: -# image.container +# FileName=image [Container] Environment=ONE=one TWO=two Image=image While `podlet -s Environment podman run -e ONE=one -e TWO=two image` results in: -# image.container +# FileName=image [Container] Environment=ONE=one Environment=TWO=two @@ -288,11 +288,11 @@ multiple times."; .try_into_files()? .into_iter() .map(|file| { - let file_name = format!("{}.{}", file.name(), file.extension()); - let file = file - .serialize(&join_options) - .wrap_err_with(|| format! {"error serializing file `{file_name}`"})?; - Ok(format!("# {file_name}\n{file}")) + let file_name = file.name(); + let serialized_file = file.serialize(&join_options).wrap_err_with(|| { + format!("error serializing {} file `{file_name}`", file.extension()) + })?; + Ok(format!("# FileName={file_name}\n{serialized_file}")) }) .collect::>>()? .join("\n---\n\n"); From ed066c4aa1eb5a4f83b8182c0e841b8ef18df871 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Thu, 2 Apr 2026 00:20:49 -0500 Subject: [PATCH 3/4] feat: add `podlet --quadlets-file` option Create a single `.quadlets` file instead of multiple Quadlet files. Signed-off-by: Paul Nettleton --- src/cli.rs | 146 +++++++++++++++++++++++++++++++++------------ src/cli/compose.rs | 2 + 2 files changed, 111 insertions(+), 37 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index fe586b4..f68337d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -52,7 +52,7 @@ use self::{ #[derive(Parser, Debug, Clone, PartialEq)] #[command(author, version, about, subcommand_precedence_over_arg = true)] pub struct Cli { - /// Generate a file instead of printing to stdout + /// Generate file(s) instead of printing to stdout /// /// Optionally provide a path for the file, /// if no path is provided the file will be placed in the current working directory. @@ -62,34 +62,49 @@ pub struct Cli { /// the filename of the kube file, /// the container name, /// or the name of the container image. - #[arg(short, long, group = "file_out")] + #[arg(short, long, groups = ["file_out", "file_or_unit_dir"])] file: Option>, - /// Generate a file in the Podman unit directory instead of printing to stdout + /// Generate file(s) in the Podman unit directory instead of printing to stdout /// /// Conflicts with the --file option /// /// Equivalent to `--file $XDG_CONFIG_HOME/containers/systemd/` for non-root users, /// or `--file /etc/containers/systemd/` for root. /// - /// The name of the file can be specified with the --name option. + /// The name of the file can be specified with the `--name` option. #[arg( short, long, visible_alias = "unit-dir", conflicts_with = "file", - group = "file_out" + groups = ["file_out", "file_or_unit_dir"] )] unit_directory: bool, + /// Generate a single `.quadlets` file instead of separate Quadlet files + /// + /// Provide the name (without the extension) that the `.quadlets` file should use. + /// + /// If `--file` or `--unit-directory` is not used, `--file` is implied with the current working + /// directory. + #[arg(long, value_name = "NAME", group = "file_out")] + quadlets_file: Option, + /// Override the name of the generated file (without the extension) /// - /// This only applies if a file was not given to the --file option, - /// or the --unit-directory option was used. + /// This only applies if a directory was given to `--file`, or `--unit-directory` was used. /// /// E.g. `podlet --file --name hello-world podman run quay.io/podman/hello` /// will generate a file with the name "hello-world.container". - #[arg(short, long, requires = "file_out")] + /// + /// Conflicts with the `--quadlets-file` option as the file name is set with it. + #[arg( + short, + long, + requires = "file_or_unit_dir", + conflicts_with = "quadlets_file" + )] name: Option, /// Overwrite existing files when generating a file @@ -249,18 +264,33 @@ multiple times."; let split_options = self.split_options.iter().copied().collect(); let join_options = &JoinOption::all_set() - &split_options; - if self.unit_directory || self.file.is_some() { + if self.unit_directory || self.file.is_some() || self.quadlets_file.is_some() { + // file out let path = self.file_path()?; - if matches!(path, FilePath::Full(..)) - && matches!(self.command, Commands::Compose { .. }) + let quadlets_file = self.quadlets_file.clone(); + if matches!(path, FilePath::Full(_)) { + if quadlets_file.is_some() { + return Err(eyre!( + "A file path was provided to `--file` and `--quadlets-file` was used`" + ) + .note("The file name is set by `--quadlets-file`.") + .suggestion("Provide a directory to `--file`.")); + } + if matches!(self.command, Commands::Compose(_)) { + return Err(eyre!( + "A file path was provided to `--file` and the `compose` command was used" + ) + .warning("`compose` can generate multiple files so a directory is needed.") + .suggestion("Provide a directory to `--file`.")); + } + } + if quadlets_file.is_some() + && matches!(self.command, Commands::Compose(Compose { kube: true, .. })) { - return Err(eyre!( - "A file path was provided to `--file` and the `compose` command was used" - ) - .suggestion( - "Provide a directory to `--file`. \ - `compose` can generate multiple files so a directory is needed.", - )); + return Err( + eyre!("cannot set both `--quadlets-file` and `compose --kube`") + .warning("`.quadlets` files cannot include Kubernetes YAML in them."), + ); } let overwrite = self.overwrite; @@ -278,24 +308,25 @@ multiple times."; )?; } - for file in files { - file.write(&path, overwrite, &join_options)?; + if let Some(quadlets_file) = quadlets_file { + let contents = files_to_quadlets_file(&files, &join_options)?; + let path = path.to_full(&quadlets_file, "quadlets"); + + open_file(&path, overwrite)? + .write_all(contents.as_bytes()) + .wrap_err_with(|| format!("error writing to file `{}`", path.display()))?; + + println!("Wrote to file: {}", path.display()); + } else { + for file in files { + file.write(&path, overwrite, &join_options)?; + } } Ok(()) } else { - let files = self - .try_into_files()? - .into_iter() - .map(|file| { - let file_name = file.name(); - let serialized_file = file.serialize(&join_options).wrap_err_with(|| { - format!("error serializing {} file `{file_name}`", file.extension()) - })?; - Ok(format!("# FileName={file_name}\n{serialized_file}")) - }) - .collect::>>()? - .join("\n---\n\n"); + let files = self.try_into_files()?; + let files = files_to_quadlets_file(&files, &join_options)?; print!("{files}"); Ok(()) } @@ -457,19 +488,60 @@ enum FilePath { impl FilePath { /// Convert to full file path /// - /// If `self` is a directory, the [`File`] is used to set the filename. - fn to_full(&self, file: &File) -> Cow<'_, Path> { + /// If `self` is a directory, `filename` and `extension` are used to set the filename. + fn to_full(&self, filename: &str, extension: &str) -> Cow<'_, Path> { match self { Self::Full(path) => path.into(), Self::Dir(path) => { - let mut path = path.join(file.name()); - path.set_extension(file.extension()); + let mut path = path.join(filename); + path.set_extension(extension); path.into() } } } } +/// Serialize each [`File`] and join them together in the `.quadlets` file format. +/// +/// # Errors +/// +/// Returns an error if a file fails to serialize. +fn files_to_quadlets_file<'a>( + files: impl IntoIterator, + join_options: &HashSet, +) -> color_eyre::Result { + files + .into_iter() + .map(|file| { + let file_name = file.name(); + + let serialized_file = file.serialize(join_options).wrap_err_with(|| { + format!("error serializing {} file `{file_name}`", file.extension()) + })?; + + let mut content = String::with_capacity(serialized_file.capacity()); + content.push_str("# "); + + match file { + File::Quadlet(_) => { + content.push_str("FileName="); + content.push_str(file_name); + } + File::Kubernetes(_) => { + content.push_str(file_name); + content.push_str(".yaml"); + } + } + + content.push('\n'); + content.push_str(&serialized_file); + + Ok(content) + }) + .collect::>>() + .map(|files| files.join("\n---\n\n")) +} + #[derive(Subcommand, Debug, Clone, PartialEq)] enum Commands { /// Generate a Podman Quadlet file from a Podman command @@ -795,7 +867,7 @@ impl File { overwrite: bool, join_options: &HashSet, ) -> color_eyre::Result<()> { - let path = path.to_full(self); + let path = path.to_full(self.name(), self.extension()); let mut file = open_file(&path, overwrite)?; let path = path.display(); diff --git a/src/cli/compose.rs b/src/cli/compose.rs index d6f9e95..97afa49 100644 --- a/src/cli/compose.rs +++ b/src/cli/compose.rs @@ -59,6 +59,8 @@ pub struct Compose { /// /// The top-level `name` field in the compose file is required when using this option. /// It is used for the name of the pod and in the filenames of the created files. + /// + /// Conflicts with `--quadlets-file` as a YAML file cannot be included in `.quadlets` file. #[arg(long, conflicts_with = "pod")] pub kube: bool, From c2b5e144acae21cf167843b5668444e316e64ebb Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Thu, 2 Apr 2026 00:21:12 -0500 Subject: [PATCH 4/4] feat(container): add `AppArmor=` Quadlet option Signed-off-by: Paul Nettleton --- src/cli/container.rs | 10 +++------- src/cli/container/security_opt.rs | 17 ++++++++++++++--- src/quadlet/container.rs | 13 +++++++++++++ 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/cli/container.rs b/src/cli/container.rs index b9f69bc..fc687a6 100644 --- a/src/cli/container.rs +++ b/src/cli/container.rs @@ -119,6 +119,7 @@ impl From for crate::quadlet::Container { let mut podman_args = podman_args.to_string(); let security_opt::QuadletOptions { + app_armor, mask, no_new_privileges, seccomp_profile, @@ -129,13 +130,7 @@ impl From for crate::quadlet::Container { security_label_type, unmask, podman_args: security_podman_args, - } = security_opt.into_iter().fold( - security_opt::QuadletOptions::default(), - |mut security_options, security_opt| { - security_options.add_security_opt(security_opt); - security_options - }, - ); + } = security_opt.into_iter().collect(); for arg in security_podman_args { podman_args.push_str(" --security-opt "); @@ -143,6 +138,7 @@ impl From for crate::quadlet::Container { } Self { + app_armor, image, mask, no_new_privileges, diff --git a/src/cli/container/security_opt.rs b/src/cli/container/security_opt.rs index c7c0666..4bc0448 100644 --- a/src/cli/container/security_opt.rs +++ b/src/cli/container/security_opt.rs @@ -88,6 +88,7 @@ pub struct InvalidLabelOpt(pub String); #[derive(Debug, Default, Clone, PartialEq)] pub struct QuadletOptions { + pub app_armor: Option, pub mask: Vec, pub no_new_privileges: bool, pub seccomp_profile: Option, @@ -100,10 +101,20 @@ pub struct QuadletOptions { pub podman_args: Vec, } +impl FromIterator for QuadletOptions { + fn from_iter>(iter: T) -> Self { + iter.into_iter() + .fold(Self::default(), |mut quadlet_options, security_opt| { + quadlet_options.add_security_opt(security_opt); + quadlet_options + }) + } +} + impl QuadletOptions { - pub fn add_security_opt(&mut self, security_opt: SecurityOpt) { + fn add_security_opt(&mut self, security_opt: SecurityOpt) { match security_opt { - SecurityOpt::Apparmor(policy) => self.podman_args.push(format!("apparmor={policy}")), + SecurityOpt::Apparmor(policy) => self.app_armor = Some(policy), SecurityOpt::Label(label_opt) => self.add_label_opt(label_opt), SecurityOpt::Mask(mask) => self.mask.extend(mask.split(':').map(Into::into)), SecurityOpt::NoNewPrivileges => self.no_new_privileges = true, @@ -118,7 +129,7 @@ impl QuadletOptions { } } - pub fn add_label_opt(&mut self, label_opt: LabelOpt) { + fn add_label_opt(&mut self, label_opt: LabelOpt) { match label_opt { LabelOpt::User(user) => self.podman_args.push(format!("label=user:{user}")), LabelOpt::Role(role) => self.podman_args.push(format!("label=role:{role}")), diff --git a/src/quadlet/container.rs b/src/quadlet/container.rs index c38e505..82fba69 100644 --- a/src/quadlet/container.rs +++ b/src/quadlet/container.rs @@ -43,6 +43,9 @@ pub struct Container { #[serde(serialize_with = "seq_quote_whitespace")] pub annotation: Vec, + /// Sets the apparmor confinement profile for the container. + pub app_armor: Option, + /// Indicates whether the container will be auto-updated. pub auto_update: Option, @@ -321,6 +324,10 @@ pub struct Container { impl Downgrade for Container { fn downgrade(&mut self, version: PodmanVersion) -> Result<(), DowngradeError> { + if version < PodmanVersion::V5_8 { + self.remove_v5_8_options(); + } + if version < PodmanVersion::V5_7 { self.remove_v5_7_options(); } @@ -383,6 +390,12 @@ macro_rules! extract { } impl Container { + /// Remove Quadlet options added in Podman v5.8.0 + fn remove_v5_8_options(&mut self) { + if let Some(app_armor) = self.app_armor.take() { + self.push_arg("security-opt", format_args!("apparmor=\"{app_armor}\"")); + } + } /// Remove Quadlet options added in Podman v5.7.0 fn remove_v5_7_options(&mut self) { if !self.http_proxy {