From d9bd985dce8bdff3694400abd65eea5f0e35295e Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Sat, 2 Dec 2023 06:06:17 -0600 Subject: [PATCH 01/23] feat(container): add `AutoUpdate=` quadlet option Additionally, in `cli::container::quadlet`, refactored `impl From for crate::quadlet::Container` a bit to use `Option::map_or` and added `Notify::is_container()`. --- src/cli/container/quadlet.rs | 34 +++++++++++------- src/quadlet.rs | 70 +++++++++++++++++++++++++++++++++++- src/quadlet/container.rs | 7 +++- 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/src/cli/container/quadlet.rs b/src/cli/container/quadlet.rs index cc5c3fc..ba9388a 100644 --- a/src/cli/container/quadlet.rs +++ b/src/cli/container/quadlet.rs @@ -8,7 +8,7 @@ use clap::{Args, ValueEnum}; use color_eyre::eyre::{self, Context}; use docker_compose_types::{MapOrEmpty, Volumes}; -use crate::cli::ComposeService; +use crate::{cli::ComposeService, quadlet::AutoUpdate}; #[allow(clippy::module_name_repetitions)] #[derive(Args, Default, Debug, Clone, PartialEq)] @@ -290,6 +290,16 @@ enum Notify { Container, } +impl Notify { + /// Returns `true` if the notify is [`Container`]. + /// + /// [`Container`]: Notify::Container + #[must_use] + fn is_container(self) -> bool { + matches!(self, Self::Container) + } +} + impl Default for Notify { fn default() -> Self { Self::Conmon @@ -298,15 +308,17 @@ impl Default for Notify { impl From for crate::quadlet::Container { fn from(value: QuadletOptions) -> Self { - let (user, group) = if let Some(user) = value.user { + let mut label = value.label; + let auto_update = AutoUpdate::extract_from_labels(&mut label); + + // `--user` is in the format: `uid[:gid]` + let (user, group) = value.user.map_or((None, None), |user| { if let Some((uid, gid)) = user.split_once(':') { - (Some(String::from(uid)), Some(String::from(gid))) + (Some(uid.into()), Some(gid.into())) } else { (Some(user), None) } - } else { - (None, None) - }; + }); let mut tmpfs = value.tmpfs; let mut volatile_tmp = false; @@ -323,6 +335,7 @@ impl From for crate::quadlet::Container { add_capability: value.cap_add, add_device: value.device, annotation: value.annotation, + auto_update, container_name: value.name, drop_capability: value.cap_drop, environment: value.env, @@ -343,15 +356,12 @@ impl From for crate::quadlet::Container { health_timeout: value.health_timeout, ip: value.ip, ip6: value.ip6, - label: value.label, + label, log_driver: value.log_driver, mount: value.mount, network: value.network, rootfs: value.rootfs, - notify: match value.sdnotify { - Notify::Conmon => false, - Notify::Container => true, - }, + notify: value.sdnotify.is_container(), publish_port: value.publish, read_only: value.read_only, run_init: value.init, @@ -604,7 +614,7 @@ fn volumes_try_into_short( && service.volume_has_options(source) => { // not bind mount or anonymous volume which has options which require a - // serparate volume unit to define + // separate volume unit to define Some(Ok(format!("{source}.volume:{target}"))) } _ => Some(Ok(volume)), diff --git a/src/quadlet.rs b/src/quadlet.rs index 88bac5b..0eafdd9 100644 --- a/src/quadlet.rs +++ b/src/quadlet.rs @@ -4,7 +4,12 @@ mod kube; mod network; mod volume; -use std::fmt::{self, Display, Formatter, Write}; +use std::{ + fmt::{self, Display, Formatter, Write}, + str::FromStr, +}; + +use thiserror::Error; pub use self::{ container::Container, install::Install, kube::Kube, network::Network, volume::Volume, @@ -120,6 +125,69 @@ impl Resource { } } +/// Valid values for the `AutoUpdate=` quadlet option. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AutoUpdate { + Registry, + Local, +} + +impl AutoUpdate { + /// Extracts all valid values of the `io.containers.autoupdate` label from `labels`, + /// the last value of which is parsed into an [`AutoUpdate`]. + /// + /// Returns `None` if no valid `io.containers.autoupdate` label is found. + /// + /// `io.containers.autoupdate` labels with invalid values are retained in `labels`. + pub fn extract_from_labels(labels: &mut Vec) -> Option { + let mut auto_update = None; + labels.retain(|label| { + label + .strip_prefix("io.containers.autoupdate=") + .and_then(|value| value.parse().ok()) + .map_or(true, |value| { + auto_update = Some(value); + false + }) + }); + + auto_update + } +} + +impl AsRef for AutoUpdate { + fn as_ref(&self) -> &str { + match self { + Self::Registry => "registry", + Self::Local => "local", + } + } +} + +impl Display for AutoUpdate { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + f.write_str(self.as_ref()) + } +} + +impl FromStr for AutoUpdate { + type Err = ParseAutoUpdateError; + + fn from_str(s: &str) -> Result { + match s { + "registry" => Ok(Self::Registry), + "local" => Ok(Self::Local), + s => Err(ParseAutoUpdateError(s.into())), + } + } +} + +/// Error returned when attempting to parse an invalid [`AutoUpdate`] variant, +/// see [`AutoUpdate::from_str()`]. +#[derive(Debug, Error)] +#[error("unknown auto update variant `{0}`, must be `registry` or `local`")] +pub struct ParseAutoUpdateError(String); + fn writeln_escape_spaces(f: &mut Formatter, key: &str, words: I) -> fmt::Result where I: IntoIterator, diff --git a/src/quadlet/container.rs b/src/quadlet/container.rs index 3c97f4d..c49e6a5 100644 --- a/src/quadlet/container.rs +++ b/src/quadlet/container.rs @@ -4,7 +4,7 @@ use std::{ path::PathBuf, }; -use super::writeln_escape_spaces; +use super::{writeln_escape_spaces, AutoUpdate}; #[derive(Debug, Default, Clone, PartialEq)] #[allow(clippy::struct_excessive_bools)] @@ -12,6 +12,7 @@ pub struct Container { pub add_capability: Vec, pub add_device: Vec, pub annotation: Vec, + pub auto_update: Option, pub container_name: Option, pub drop_capability: Vec, pub environment: Vec, @@ -79,6 +80,10 @@ impl Display for Container { writeln_escape_spaces(f, "Annotation", &self.annotation)?; } + if let Some(auto_update) = self.auto_update { + writeln!(f, "AutoUpdate={auto_update}")?; + } + if let Some(name) = &self.container_name { writeln!(f, "ContainerName={name}")?; } From 9d05c326b010cc5eadcf193d513d49ddcd22a5fc Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Sun, 3 Dec 2023 00:55:09 -0600 Subject: [PATCH 02/23] feat(container): add `HostName=` quadlet option --- src/cli/container/podman.rs | 9 --------- src/cli/container/quadlet.rs | 8 ++++++++ src/quadlet/container.rs | 5 +++++ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/cli/container/podman.rs b/src/cli/container/podman.rs index 0f45a1a..0bc4198 100644 --- a/src/cli/container/podman.rs +++ b/src/cli/container/podman.rs @@ -188,10 +188,6 @@ pub struct PodmanArgs { #[arg(long, value_name = "ENTRY")] group_entry: Option, - /// Set container hostname - #[arg(long, value_name = "NAME")] - hostname: Option, - /// Add a user account to /etc/passwd from the host to the container #[arg(long, value_name = "NAME")] hostuser: Vec, @@ -478,7 +474,6 @@ impl Default for PodmanArgs { gidmap: Vec::new(), group_add: Vec::new(), group_entry: None, - hostname: None, hostuser: Vec::new(), http_proxy: true, image_volume: None, @@ -579,7 +574,6 @@ impl PodmanArgs { + self.gidmap.len() + self.group_add.len() + self.group_entry.iter().len() - + self.hostname.iter().len() + self.hostuser.len() + usize::from(!self.http_proxy) + self.image_volume.iter().len() @@ -739,8 +733,6 @@ impl Display for PodmanArgs { extend_args(&mut args, "--group-entry", &self.group_entry); - extend_args(&mut args, "--hostname", &self.hostname); - extend_args(&mut args, "--hostuser", &self.hostuser); if !self.http_proxy { @@ -951,7 +943,6 @@ impl TryFrom<&mut docker_compose_types::Service> for PodmanArgs { .collect(); Ok(Self { - hostname: value.hostname.take(), privileged: value.privileged, pid: value.pid.take(), ulimit, diff --git a/src/cli/container/quadlet.rs b/src/cli/container/quadlet.rs index ba9388a..7c9f401 100644 --- a/src/cli/container/quadlet.rs +++ b/src/cli/container/quadlet.rs @@ -151,6 +151,12 @@ pub struct QuadletOptions { #[arg(long, value_name = "TIMEOUT")] health_timeout: Option, + /// Set the host name that is available inside the container + /// + /// Converts to "HostName=NAME" + #[arg(long, value_name = "NAME")] + hostname: Option, + /// Specify a static IPv4 address for the container /// /// Converts to "IP=IPV4" @@ -354,6 +360,7 @@ impl From for crate::quadlet::Container { health_startup_success: value.health_startup_success, health_startup_timeout: value.health_startup_timeout, health_timeout: value.health_timeout, + host_name: value.hostname, ip: value.ip, ip6: value.ip6, label, @@ -495,6 +502,7 @@ impl TryFrom<&mut ComposeService> for QuadletOptions { health_retries, health_start_period, health_timeout, + hostname: service.hostname.take(), sysctl, tmpfs, mount, diff --git a/src/quadlet/container.rs b/src/quadlet/container.rs index c49e6a5..c2964cd 100644 --- a/src/quadlet/container.rs +++ b/src/quadlet/container.rs @@ -32,6 +32,7 @@ pub struct Container { pub health_startup_success: Option, pub health_startup_timeout: Option, pub health_timeout: Option, + pub host_name: Option, pub image: String, pub ip: Option, pub ip6: Option, @@ -156,6 +157,10 @@ impl Display for Container { writeln!(f, "HealthTimeout={timeout}")?; } + if let Some(host_name) = &self.host_name { + writeln!(f, "HostName={host_name}")?; + } + if let Some(ip) = &self.ip { writeln!(f, "IP={ip}")?; } From 956dec7e55b8e850eb2fe4b8440fda0e2040149c Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Sun, 3 Dec 2023 01:32:26 -0600 Subject: [PATCH 03/23] feat(container): add `Pull=` quadlet option --- src/cli/container/podman.rs | 8 -------- src/cli/container/quadlet.rs | 12 ++++++++++- src/quadlet.rs | 6 +++++- src/quadlet/container.rs | 39 ++++++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/cli/container/podman.rs b/src/cli/container/podman.rs index 0bc4198..66ccfed 100644 --- a/src/cli/container/podman.rs +++ b/src/cli/container/podman.rs @@ -318,10 +318,6 @@ pub struct PodmanArgs { #[arg(short = 'P', long)] publish_all: bool, - /// Pull image policy - #[arg(long, value_name = "POLICY")] - pull: Option, - /// Suppress output information when pulling images #[arg(short, long)] quiet: bool, @@ -506,7 +502,6 @@ impl Default for PodmanArgs { preserve_fds: None, privileged: false, publish_all: false, - pull: None, quiet: false, read_only_tmpfs: true, replace: false, @@ -599,7 +594,6 @@ impl PodmanArgs { + self.pod.iter().len() + self.pod_id_file.iter().len() + self.preserve_fds.iter().len() - + self.pull.iter().len() + usize::from(!self.read_only_tmpfs) + self.requires.iter().len() + self.seccomp_policy.iter().len() @@ -823,8 +817,6 @@ impl Display for PodmanArgs { args.push("--publish-all"); } - extend_args(&mut args, "--pull", &self.pull); - if self.quiet { args.push("--quiet"); } diff --git a/src/cli/container/quadlet.rs b/src/cli/container/quadlet.rs index 7c9f401..cfe46c9 100644 --- a/src/cli/container/quadlet.rs +++ b/src/cli/container/quadlet.rs @@ -8,7 +8,10 @@ use clap::{Args, ValueEnum}; use color_eyre::eyre::{self, Context}; use docker_compose_types::{MapOrEmpty, Volumes}; -use crate::{cli::ComposeService, quadlet::AutoUpdate}; +use crate::{ + cli::ComposeService, + quadlet::{AutoUpdate, PullPolicy}, +}; #[allow(clippy::module_name_repetitions)] #[derive(Args, Default, Debug, Clone, PartialEq)] @@ -223,6 +226,12 @@ pub struct QuadletOptions { )] publish: Vec, + /// Pull image policy + /// + /// Converts to "Pull=POLICY" + #[arg(long, value_name = "POLICY")] + pull: Option, + /// Mount the container's root filesystem as read-only /// /// Converts to "ReadOnly=true" @@ -370,6 +379,7 @@ impl From for crate::quadlet::Container { rootfs: value.rootfs, notify: value.sdnotify.is_container(), publish_port: value.publish, + pull: value.pull, read_only: value.read_only, run_init: value.init, secret: value.secret, diff --git a/src/quadlet.rs b/src/quadlet.rs index 0eafdd9..2db4e32 100644 --- a/src/quadlet.rs +++ b/src/quadlet.rs @@ -12,7 +12,11 @@ use std::{ use thiserror::Error; pub use self::{ - container::Container, install::Install, kube::Kube, network::Network, volume::Volume, + container::{Container, PullPolicy}, + install::Install, + kube::Kube, + network::Network, + volume::Volume, }; use crate::cli::{service::Service, unit::Unit}; diff --git a/src/quadlet/container.rs b/src/quadlet/container.rs index c2964cd..444170a 100644 --- a/src/quadlet/container.rs +++ b/src/quadlet/container.rs @@ -4,6 +4,8 @@ use std::{ path::PathBuf, }; +use clap::ValueEnum; + use super::{writeln_escape_spaces, AutoUpdate}; #[derive(Debug, Default, Clone, PartialEq)] @@ -45,6 +47,7 @@ pub struct Container { pub notify: bool, pub podman_args: Option, pub publish_port: Vec, + pub pull: Option, pub read_only: bool, pub run_init: bool, pub seccomp_profile: Option, @@ -201,6 +204,10 @@ impl Display for Container { writeln!(f, "PublishPort={port}")?; } + if let Some(pull) = self.pull { + writeln!(f, "Pull={pull}")?; + } + if self.read_only { writeln!(f, "ReadOnly=true")?; } @@ -272,3 +279,35 @@ impl Display for Container { Ok(()) } } + +/// Valid pull policies for container images. +/// +/// See the `--pull` [section](https://docs.podman.io/en/latest/markdown/podman-run.1.html#pull-policy) of the `podman run` documentation. +#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum PullPolicy { + /// Always pull the image and throw an error if the pull fails. + Always, + /// Pull the image only when the image is not in the local containers storage. + Missing, + /// Never pull the image but use the one from the local containers storage. + Never, + /// Pull if the image on the registry is newer than the one in the local containers storage. + Newer, +} + +impl AsRef for PullPolicy { + fn as_ref(&self) -> &str { + match self { + Self::Always => "always", + Self::Missing => "missing", + Self::Never => "never", + Self::Newer => "newer", + } + } +} + +impl Display for PullPolicy { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + f.write_str(self.as_ref()) + } +} From af54bccbc201de867075436a1c892cf1edaabff0 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Sun, 3 Dec 2023 01:49:47 -0600 Subject: [PATCH 04/23] feat(container): add `WorkingDir=` quadlet option --- src/cli/container/podman.rs | 12 +----------- src/cli/container/quadlet.rs | 8 ++++++++ src/quadlet/container.rs | 5 +++++ 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/cli/container/podman.rs b/src/cli/container/podman.rs index 66ccfed..c186644 100644 --- a/src/cli/container/podman.rs +++ b/src/cli/container/podman.rs @@ -423,10 +423,6 @@ pub struct PodmanArgs { /// Can be specified multiple times #[arg(long, value_name = "CONTAINER[:OPTIONS]")] volumes_from: Vec, - - /// Working directory inside the container - #[arg(short, long, value_name = "DIR")] - workdir: Option, } impl Default for PodmanArgs { @@ -525,7 +521,6 @@ impl Default for PodmanArgs { umask: None, variant: None, volumes_from: Vec::new(), - workdir: None, } } } @@ -611,8 +606,7 @@ impl PodmanArgs { + self.ulimit.len() + self.umask.iter().len() + self.variant.iter().len() - + self.volumes_from.len() - + self.workdir.iter().len()) + + self.volumes_from.len()) * 2 + usize::from(self.interactive) + usize::from(self.no_healthcheck) @@ -870,9 +864,6 @@ impl Display for PodmanArgs { extend_args(&mut args, "--volumes-from", &self.volumes_from); - let workdir = self.workdir.as_deref().map(Path::to_string_lossy); - extend_args(&mut args, "--workdir", &workdir); - debug_assert_eq!(args.len(), self.args_len()); write!(f, "{}", shlex::join(args)) @@ -943,7 +934,6 @@ impl TryFrom<&mut docker_compose_types::Service> for PodmanArgs { stop_timeout, dns: mem::take(&mut value.dns), ipc: value.ipc.take(), - workdir: value.working_dir.take().map(Into::into), interactive: value.stdin_open, shm_size: value.shm_size.take(), log_opt, diff --git a/src/cli/container/quadlet.rs b/src/cli/container/quadlet.rs index cfe46c9..cda7c03 100644 --- a/src/cli/container/quadlet.rs +++ b/src/cli/container/quadlet.rs @@ -297,6 +297,12 @@ pub struct QuadletOptions { value_name = "[[SOURCE-VOLUME|HOST-DIR:]CONTAINER-DIR[:OPTIONS]]" )] volume: Vec, + + /// Working directory inside the container + /// + /// Converts to "WorkingDir=DIR" + #[arg(short, long, value_name = "DIR")] + workdir: Option, } #[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] @@ -390,6 +396,7 @@ impl From for crate::quadlet::Container { user_ns: value.userns, volatile_tmp, volume: value.volume, + working_dir: value.workdir, ..Self::default() } } @@ -524,6 +531,7 @@ impl TryFrom<&mut ComposeService> for QuadletOptions { .map(|logging| mem::take(&mut logging.driver)), init: service.init, volume, + workdir: service.working_dir.take().map(Into::into), ..Self::default() }) } diff --git a/src/quadlet/container.rs b/src/quadlet/container.rs index 444170a..483fc76 100644 --- a/src/quadlet/container.rs +++ b/src/quadlet/container.rs @@ -63,6 +63,7 @@ pub struct Container { pub user_ns: Option, pub volatile_tmp: bool, pub volume: Vec, + pub working_dir: Option, } impl Display for Container { @@ -268,6 +269,10 @@ impl Display for Container { writeln!(f, "Volume={volume}")?; } + if let Some(working_dir) = &self.working_dir { + writeln!(f, "WorkingDir={}", working_dir.display())?; + } + if let Some(podman_args) = &self.podman_args { writeln!(f, "PodmanArgs={podman_args}")?; } From 1a116665ee600ffbb2a486f25ff0ed9069194a37 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Sun, 3 Dec 2023 03:03:40 -0600 Subject: [PATCH 05/23] feat(container): add `SecurityLabelNested=` quadlet option --- src/cli/container.rs | 2 ++ src/cli/container/security_opt.rs | 3 ++- src/quadlet/container.rs | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/cli/container.rs b/src/cli/container.rs index 747c58b..8751a0f 100644 --- a/src/cli/container.rs +++ b/src/cli/container.rs @@ -116,6 +116,7 @@ impl From for crate::quadlet::Container { security_label_disable, security_label_file_type, security_label_level, + security_label_nested, security_label_type, podman_args: security_podman_args, } = security_opt.into_iter().fold( @@ -138,6 +139,7 @@ impl From for crate::quadlet::Container { security_label_disable, security_label_file_type, security_label_level, + security_label_nested, security_label_type, podman_args: (!podman_args.is_empty()).then(|| podman_args.trim().to_string()), exec: (!command.is_empty()).then(|| shlex::join(command.iter().map(String::as_str))), diff --git a/src/cli/container/security_opt.rs b/src/cli/container/security_opt.rs index fa6638a..4296e08 100644 --- a/src/cli/container/security_opt.rs +++ b/src/cli/container/security_opt.rs @@ -110,6 +110,7 @@ pub struct QuadletOptions { pub security_label_disable: bool, pub security_label_file_type: Option, pub security_label_level: Option, + pub security_label_nested: bool, pub security_label_type: Option, pub podman_args: Vec, } @@ -137,7 +138,7 @@ impl QuadletOptions { LabelOpt::Level(level) => self.security_label_level = Some(level), LabelOpt::Filetype(file_type) => self.security_label_file_type = Some(file_type), LabelOpt::Disable => self.security_label_disable = true, - LabelOpt::Nested => self.podman_args.push(String::from("nested")), + LabelOpt::Nested => self.security_label_nested = true, } } } diff --git a/src/quadlet/container.rs b/src/quadlet/container.rs index 483fc76..26b1c50 100644 --- a/src/quadlet/container.rs +++ b/src/quadlet/container.rs @@ -54,6 +54,7 @@ pub struct Container { pub security_label_disable: bool, pub security_label_file_type: Option, pub security_label_level: Option, + pub security_label_nested: bool, pub security_label_type: Option, pub secret: Vec, pub sysctl: Vec, @@ -233,6 +234,10 @@ impl Display for Container { writeln!(f, "SecurityLabelLevel={level}")?; } + if self.security_label_nested { + writeln!(f, "SecurityLabelNested=true")?; + } + if let Some(label_type) = &self.security_label_type { writeln!(f, "SecurityLabelType={label_type}")?; } From 5030521054c5a7fbc983b00420755a61d6d11310 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Tue, 5 Dec 2023 04:23:23 -0600 Subject: [PATCH 06/23] refactor: use custom serializer for `PodmanArgs=` Created a custom serde `Serializer` for serializing into command arguments and added a `Serialize` derive to `cli::container::podman::PodmanArgs`. This replaces the manaul `Display` impl, improving readability and reducing chances for errors. --- Cargo.lock | 1 + Cargo.toml | 1 + src/cli/container/podman.rs | 459 ++++++---------------- src/main.rs | 3 + src/serde/args.rs | 741 ++++++++++++++++++++++++++++++++++++ 5 files changed, 863 insertions(+), 342 deletions(-) create mode 100644 src/serde/args.rs diff --git a/Cargo.lock b/Cargo.lock index bba3342..44225a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1070,6 +1070,7 @@ dependencies = [ "ipnet", "k8s-openapi", "nix", + "serde", "serde_yaml", "shlex", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index aa4eda8..e226f50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ duration-str = { version = "0.7", default-features = false } indexmap = "2" ipnet = "2.7" k8s-openapi = { version = "0.20", features = ["latest"] } +serde = { version = "1", features = ["derive"] } serde_yaml = "0.9.21" shlex = "1.1" thiserror = "1.0.40" diff --git a/src/cli/container/podman.rs b/src/cli/container/podman.rs index c186644..ccb5bd3 100644 --- a/src/cli/container/podman.rs +++ b/src/cli/container/podman.rs @@ -1,430 +1,532 @@ use std::{ fmt::{self, Display, Formatter}, mem, - path::{Path, PathBuf}, + ops::Not, + path::PathBuf, }; use clap::{ArgAction, Args}; use color_eyre::eyre::Context; +use serde::Serialize; #[allow(clippy::struct_excessive_bools, clippy::module_name_repetitions)] -#[derive(Args, Debug, Clone, PartialEq)] +#[derive(Args, Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] pub struct PodmanArgs { /// Add a custom host-to-IP mapping /// /// Can be specified multiple times #[arg(long, value_name = "HOST:IP")] + #[serde(skip_serializing_if = "Vec::is_empty")] add_host: Vec, /// Override the architecture of the image to be pulled /// /// Defaults to hosts architecture #[arg(long)] + #[serde(skip_serializing_if = "Option::is_none")] arch: Option, /// Attach to STDIN, STDOUT, or STDERR #[arg(short, long, value_name = "STDIN | STDOUT | STDERR")] + #[serde(skip_serializing_if = "Vec::is_empty")] attach: Vec, /// Path of the authentication file /// /// Default is `${XDG_RUNTIME_DIR}/containers/auth.json` #[arg(long, value_name = "PATH")] + #[serde(skip_serializing_if = "Option::is_none")] authfile: Option, /// Block IO relative weight, between 10 and 1000 #[arg(long, value_name = "WEIGHT")] + #[serde(skip_serializing_if = "Option::is_none")] blkio_weight: Option, /// Block IO relative device weight #[arg(long, value_name = "DEVICE:WEIGHT")] + #[serde(skip_serializing_if = "Option::is_none")] blkio_weight_device: Option, /// Specify the cgroup file to write to and its value /// /// Can be specified multiple times #[arg(long, value_name = "KEY=VALUE")] + #[serde(skip_serializing_if = "Vec::is_empty")] cgroup_conf: Vec, /// Path to cgroups under which the cgroup for the container will be created #[arg(long, value_name = "PATH")] + #[serde(skip_serializing_if = "Option::is_none")] cgroup_parent: Option, /// Set the cgroup namespace for the container #[arg(long, value_name = "MODE")] + #[serde(skip_serializing_if = "Option::is_none")] cgroupns: Option, /// Whether the container will create cgroups #[arg(long, value_name = "HOW")] + #[serde(skip_serializing_if = "Option::is_none")] cgroups: Option, /// Chroot directories inside the container #[arg(long, value_name = "PATH")] + #[serde(skip_serializing_if = "Option::is_none")] chrootdirs: Option, /// Write container ID to a file #[arg(long, value_name = "FILE")] + #[serde(skip_serializing_if = "Option::is_none")] cidfile: Option, /// Write the pid of the conmon process to a file #[arg(long, value_name = "FILE")] + #[serde(skip_serializing_if = "Option::is_none")] conmon_pidfile: Option, /// Limit the CPU CFS (Completely Fair Scheduler) period #[arg(long, value_name = "LIMIT")] + #[serde(skip_serializing_if = "Option::is_none")] cpu_period: Option, /// Limit the CPU CFS (Completely Fair Scheduler) quota #[arg(long, value_name = "LIMIT")] + #[serde(skip_serializing_if = "Option::is_none")] cpu_quota: Option, /// Limit the CPU real-time period in microseconds #[arg(long, value_name = "MICROSECONDS")] + #[serde(skip_serializing_if = "Option::is_none")] cpu_rt_period: Option, /// Limit the CPU real-time runtime in microseconds #[arg(long, value_name = "MICROSECONDS")] + #[serde(skip_serializing_if = "Option::is_none")] cpu_rt_runtime: Option, /// CPU shares (relative weight) #[arg(short, long, value_name = "SHARES")] + #[serde(skip_serializing_if = "Option::is_none")] cpu_shares: Option, /// Number of CPUs #[arg(long, value_name = "NUMBER")] + #[serde(skip_serializing_if = "Option::is_none")] cpus: Option, /// CPUs in which to allow execution #[arg(long, value_name = "NUMBER")] + #[serde(skip_serializing_if = "Option::is_none")] cpuset_cpus: Option, /// Memory nodes (MEMs) in which to allow execution #[arg(long, value_name = "NODES")] + #[serde(skip_serializing_if = "Option::is_none")] cpuset_mems: Option, /// Key needed to decrypt the image #[arg(long, value_name = "KEY[:PASSPHRASE]")] + #[serde(skip_serializing_if = "Option::is_none")] decryption_key: Option, /// Detached mode: run the container in the background /// /// Automatically set by quadlet #[arg(short, long)] + #[serde(skip_serializing)] detach: bool, /// Key sequence for detaching a container #[arg(long, value_name = "SEQUENCE")] + #[serde(skip_serializing_if = "Option::is_none")] detach_keys: Option, /// Add a rule to the cgroup allowed devices list /// /// Can be specified multiple times #[arg(long, value_name = "TYPE MAJOR:MINOR MODE")] + #[serde(skip_serializing_if = "Vec::is_empty")] device_cgroup_rule: Vec, /// Limit read rate (in bytes per second) from a device /// /// Can be specified multiple times #[arg(long, value_name = "PATH:RATE")] + #[serde(skip_serializing_if = "Vec::is_empty")] device_read_bps: Vec, /// Limit read rate (in IO operations per second) from a device /// /// Can be specified multiple times #[arg(long, value_name = "PATH:RATE")] + #[serde(skip_serializing_if = "Vec::is_empty")] device_read_iops: Vec, /// Limit write rate (in bytes per second) to a device /// /// Can be specified multiple times #[arg(long, value_name = "PATH:RATE")] + #[serde(skip_serializing_if = "Vec::is_empty")] device_write_bps: Vec, /// Limit write rate (in IO operations per second) to a device /// /// Can be specified multiple times #[arg(long, value_name = "PATH:RATE")] + #[serde(skip_serializing_if = "Vec::is_empty")] device_write_iops: Vec, /// This is a Docker specific option and is a NOOP #[arg(long)] + #[serde(skip_serializing_if = "Not::not")] disable_content_trust: bool, /// Set custom DNS servers #[arg(long, value_name = "IP_ADDRESS")] + #[serde(skip_serializing_if = "Vec::is_empty")] dns: Vec, /// Set custom DNS options #[arg(long, value_name = "OPTION")] + #[serde(skip_serializing_if = "Option::is_none")] dns_option: Option, /// Set custom DNS search domains #[arg(long, value_name = "DOMAIN")] + #[serde(skip_serializing_if = "Option::is_none")] dns_search: Option, /// Override the default entrypoint of the image #[arg(long, value_name = "\"COMMAND\" | '[\"COMMAND\", \"ARG1\", ...]'")] + #[serde(skip_serializing_if = "Option::is_none")] entrypoint: Option, /// Preprocess default environment variables for the container /// /// Can be specified multiple times #[arg(long, value_name = "ENV")] + #[serde(skip_serializing_if = "Vec::is_empty")] env_merge: Vec, /// Run the container in a new user namespace using the supplied GID mapping /// /// Can be specified multiple times #[arg(long, value_name = "CONTAINER_GID:HOST_GID:AMOUNT")] + #[serde(skip_serializing_if = "Vec::is_empty")] gidmap: Vec, /// Assign additional groups to the primary user running within the container process /// /// Can be specified multiple times #[arg(long, value_name = "GROUP")] + #[serde(skip_serializing_if = "Vec::is_empty")] group_add: Vec, /// Customize the entry that is written to the /etc/group file within the container #[arg(long, value_name = "ENTRY")] + #[serde(skip_serializing_if = "Option::is_none")] group_entry: Option, /// Add a user account to /etc/passwd from the host to the container #[arg(long, value_name = "NAME")] + #[serde(skip_serializing_if = "Vec::is_empty")] hostuser: Vec, /// Set proxy environment variables in the container based on the host proxy vars #[arg(long, action = ArgAction::Set, default_value_t = true)] + #[serde(skip_serializing_if = "skip_true")] http_proxy: bool, /// How to handle the builtin image volumes #[arg(long, value_name = "bind | tmpfs | ignore")] + #[serde(skip_serializing_if = "Option::is_none")] image_volume: Option, /// Path to the container-init binary #[arg(long, value_name = "PATH")] + #[serde(skip_serializing_if = "Option::is_none")] init_path: Option, /// keep stdin open even if not attached #[arg(short, long)] + #[serde(skip_serializing_if = "Not::not")] interactive: bool, /// Set the IPC namespace mode for the container #[arg(long)] + #[serde(skip_serializing_if = "Option::is_none")] ipc: Option, /// Read in a line-delimited file of labels #[arg(long, value_name = "FILE")] + #[serde(skip_serializing_if = "Option::is_none")] label_file: Option, /// Not implemented #[arg(long, value_name = "IP")] + #[serde(skip_serializing_if = "Option::is_none")] link_local_ip: Option, /// Logging driver specific options /// /// Can be specified multiple times #[arg(long, value_name = "NAME=VALUE")] + #[serde(skip_serializing_if = "Vec::is_empty")] log_opt: Vec, /// Container network interface MAC address #[arg(long, value_name = "ADDRESS")] + #[serde(skip_serializing_if = "Option::is_none")] mac_address: Option, /// Memory limit #[arg(short, long, value_name = "NUMBER[UNIT]")] + #[serde(skip_serializing_if = "Option::is_none")] memory: Option, /// Memory soft limit #[arg(long, value_name = "NUMBER[UNIT]")] + #[serde(skip_serializing_if = "Option::is_none")] memory_reservation: Option, /// Limit value equal to memory plus swap #[arg(long, value_name = "NUMBER[UNIT]")] + #[serde(skip_serializing_if = "Option::is_none")] memory_swap: Option, /// Tune the container’s memory swappiness behavior #[arg(long, value_name = "NUMBER")] + #[serde(skip_serializing_if = "Option::is_none")] memory_swappiness: Option, /// Add a network-scoped alias for the container #[arg(long, value_name = "ALIAS")] + #[serde(skip_serializing_if = "Option::is_none")] network_alias: Option, /// Disable healthchecks on the container #[arg(long)] + #[serde(skip_serializing_if = "Not::not")] no_healthcheck: bool, /// Do not create /etc/hosts for the container #[arg(long)] + #[serde(skip_serializing_if = "Not::not")] no_hosts: bool, /// Disable OOM Killer for the container #[arg(long)] + #[serde(skip_serializing_if = "Not::not")] oom_kill_disable: bool, /// Tune the host’s OOM preferences for the container #[arg(long, value_name = "NUM")] + #[serde(skip_serializing_if = "Option::is_none")] oom_score_adj: Option, /// Override the OS, defaults to hosts, of the image to be pulled #[arg(long)] + #[serde(skip_serializing_if = "Option::is_none")] os: Option, /// Add entries to /etc/passwd and /etc/group when used with the --user option #[arg(long)] + #[serde(skip_serializing_if = "Not::not")] passwd: bool, /// Entry to write to /etc/passwd #[arg(long, value_name = "ENTRY")] + #[serde(skip_serializing_if = "Option::is_none")] passwd_entry: Option, /// Configure execution domain using personality #[arg(long, value_name = "PERSONA")] + #[serde(skip_serializing_if = "Option::is_none")] personality: Option, /// Set the PID namespace mode for the container #[arg(long, value_name = "MODE")] + #[serde(skip_serializing_if = "Option::is_none")] pid: Option, /// Write the container process ID to the file #[arg(long, value_name = "PATH")] + #[serde(skip_serializing_if = "Option::is_none")] pidfile: Option, /// Tune the container’s pids limit #[arg(long, value_name = "LIMIT")] + #[serde(skip_serializing_if = "Option::is_none")] pids_limit: Option, /// Specify the platform for selecting the image #[arg(long, value_name = "OS/ARCH")] + #[serde(skip_serializing_if = "Option::is_none")] platform: Option, /// Run the container in an existing pod #[arg(long, value_name = "NAME")] + #[serde(skip_serializing_if = "Option::is_none")] pod: Option, /// Read the pod ID from the file #[arg(long, value_name = "FILE")] + #[serde(skip_serializing_if = "Option::is_none")] pod_id_file: Option, /// Pass a number of additional file descriptors into the container #[arg(long, value_name = "N")] + #[serde(skip_serializing_if = "Option::is_none")] preserve_fds: Option, /// Give extended privileges to the container #[arg(long)] + #[serde(skip_serializing_if = "Not::not")] privileged: bool, /// Publish all exposed ports to random ports on the host interfaces #[arg(short = 'P', long)] + #[serde(skip_serializing_if = "Not::not")] publish_all: bool, /// Suppress output information when pulling images #[arg(short, long)] + #[serde(skip_serializing_if = "Not::not")] quiet: bool, /// When running containers in read-only mode mount a read-write tmpfs on /run, /tmp and /var/tmp #[arg(long, action = ArgAction::Set, default_value_t = true)] + #[serde(skip_serializing_if = "skip_true")] read_only_tmpfs: bool, /// If a container with the same name exists, replace it /// /// Automatically set by quadlet #[arg(long)] + #[serde(skip_serializing)] replace: bool, /// Add one or more requirement containers #[arg(long, value_name = "CONTAINER[,...]")] + #[serde(skip_serializing_if = "Option::is_none")] requires: Option, /// Remove container (and pod if created) after exit /// /// Automatically set by quadlet #[arg(long)] + #[serde(skip_serializing)] rm: bool, /// After the container exits, remove the container image unless it is used by other containers #[arg(long)] + #[serde(skip_serializing_if = "Not::not")] rmi: bool, /// Specify the policy to select the seccomp profile #[arg(long, value_name = "POLICY")] + #[serde(skip_serializing_if = "Option::is_none")] seccomp_policy: Option, /// Size of /dev/shm #[arg(long, value_name = "NUMBER[UNIT]")] + #[serde(skip_serializing_if = "Option::is_none")] shm_size: Option, /// Size of systemd-specific tmpfs mounts: /run, /run/lock, /var/log/journal, and /tmp #[arg(long, value_name = "NUMBER[UNIT]")] + #[serde(skip_serializing_if = "Option::is_none")] shm_size_systemd: Option, /// Proxy received signals to the container process #[arg(long, action = ArgAction::Set, default_value_t = true)] + #[serde(skip_serializing_if = "skip_true")] sig_proxy: bool, /// Signal to stop a container #[arg(long, value_name = "SIGNAL")] + #[serde(skip_serializing_if = "Option::is_none")] stop_signal: Option, /// Timeout to stop a container /// /// Default is 10 #[arg(long, value_name = "SECONDS")] + #[serde(skip_serializing_if = "Option::is_none")] stop_timeout: Option, /// Name of range listed in /etc/subgid for use in user namespace #[arg(long, value_name = "NAME")] + #[serde(skip_serializing_if = "Option::is_none")] subgidname: Option, /// Name of range listed in /etc/subuid for use in user namespace #[arg(long, value_name = "NAME")] + #[serde(skip_serializing_if = "Option::is_none")] subuidname: Option, /// Run container in systemd mode /// /// Default is true #[arg(long, value_name = "true | false | always")] + #[serde(skip_serializing_if = "Option::is_none")] systemd: Option, /// Maximum length of time a container is allowed to run #[arg(long, value_name = "SECONDS")] + #[serde(skip_serializing_if = "Option::is_none")] timeout: Option, /// Require HTTPS and verify certificates when contacting registries #[arg(long)] + #[serde(skip_serializing_if = "Option::is_none")] tls_verify: Option, /// Allocate a pseudo-TTY #[arg(short, long)] + #[serde(skip_serializing_if = "Not::not")] tty: bool, /// Run the container in a new user namespace using the supplied UID mapping /// /// Can be specified multiple times #[arg(long, value_name = "CONTAINER_UID:FROM_UID:AMOUNT")] + #[serde(skip_serializing_if = "Vec::is_empty")] uidmap: Vec, /// Ulimit options /// /// Can be specified multiple times #[arg(long, value_name = "OPTION")] + #[serde(skip_serializing_if = "Vec::is_empty")] ulimit: Vec, /// Set the umask inside the container #[arg(long)] + #[serde(skip_serializing_if = "Option::is_none")] umask: Option, /// Set variant to use instead of the default architecture variant of the container image #[arg(long)] + #[serde(skip_serializing_if = "Option::is_none")] variant: Option, /// Mount volumes from the specified container /// /// Can be specified multiple times #[arg(long, value_name = "CONTAINER[:OPTIONS]")] + #[serde(skip_serializing_if = "Vec::is_empty")] volumes_from: Vec, } +// ref required for serde's skip_serializing_if +#[allow(clippy::trivially_copy_pass_by_ref)] +fn skip_true(bool: &bool) -> bool { + *bool +} + impl Default for PodmanArgs { fn default() -> Self { Self { @@ -525,348 +627,10 @@ impl Default for PodmanArgs { } } -impl PodmanArgs { - /// The total resulting number of arguments - fn args_len(&self) -> usize { - (self.add_host.len() - + self.arch.iter().len() - + self.attach.len() - + self.authfile.iter().len() - + self.blkio_weight.iter().len() - + self.blkio_weight_device.iter().len() - + self.cgroup_conf.len() - + self.cgroup_parent.iter().len() - + self.cgroupns.iter().len() - + self.cgroups.iter().len() - + self.chrootdirs.iter().len() - + self.cidfile.iter().len() - + self.conmon_pidfile.iter().len() - + self.cpu_period.iter().len() - + self.cpu_quota.iter().len() - + self.cpu_rt_period.iter().len() - + self.cpu_rt_runtime.iter().len() - + self.cpu_shares.iter().len() - + self.cpus.iter().len() - + self.cpuset_cpus.iter().len() - + self.cpuset_mems.iter().len() - + self.decryption_key.iter().len() - + self.detach_keys.iter().len() - + self.device_cgroup_rule.len() - + self.device_read_bps.len() - + self.device_read_iops.len() - + self.device_write_bps.len() - + self.device_write_iops.len() - + self.dns.len() - + self.dns_option.iter().len() - + self.dns_search.iter().len() - + self.entrypoint.iter().len() - + self.env_merge.len() - + self.gidmap.len() - + self.group_add.len() - + self.group_entry.iter().len() - + self.hostuser.len() - + usize::from(!self.http_proxy) - + self.image_volume.iter().len() - + self.init_path.iter().len() - + self.ipc.iter().len() - + self.label_file.iter().len() - + self.link_local_ip.iter().len() - + self.log_opt.len() - + self.mac_address.iter().len() - + self.memory.iter().len() - + self.memory_reservation.iter().len() - + self.memory_swap.iter().len() - + self.memory_swappiness.iter().len() - + self.network_alias.iter().len() - + self.oom_score_adj.iter().len() - + self.os.iter().len() - + self.passwd_entry.iter().len() - + self.personality.iter().len() - + self.pid.iter().len() - + self.pidfile.iter().len() - + self.pids_limit.iter().len() - + self.platform.iter().len() - + self.pod.iter().len() - + self.pod_id_file.iter().len() - + self.preserve_fds.iter().len() - + usize::from(!self.read_only_tmpfs) - + self.requires.iter().len() - + self.seccomp_policy.iter().len() - + self.shm_size.iter().len() - + self.shm_size_systemd.iter().len() - + usize::from(!self.sig_proxy) - + self.stop_signal.iter().len() - + self.stop_timeout.iter().len() - + self.subgidname.iter().len() - + self.subuidname.iter().len() - + self.systemd.iter().len() - + self.timeout.iter().len() - + self.tls_verify.iter().len() - + self.uidmap.len() - + self.ulimit.len() - + self.umask.iter().len() - + self.variant.iter().len() - + self.volumes_from.len()) - * 2 - + usize::from(self.interactive) - + usize::from(self.no_healthcheck) - + usize::from(self.no_hosts) - + usize::from(self.oom_kill_disable) - + usize::from(self.passwd) - + usize::from(self.privileged) - + usize::from(self.publish_all) - + usize::from(self.quiet) - + usize::from(self.rmi) - + usize::from(self.tty) - } -} - -fn extend_args<'a, T, U>(args: &mut Vec<&'a str>, arg: &'a str, values: T) -where - T: IntoIterator, - U: 'a + AsRef, -{ - args.extend(values.into_iter().flat_map(|value| [arg, value.as_ref()])); -} - impl Display for PodmanArgs { - #[allow(clippy::similar_names, clippy::too_many_lines)] fn fmt(&self, f: &mut Formatter) -> fmt::Result { - let mut args = Vec::with_capacity(self.args_len()); - - extend_args(&mut args, "--add-host", &self.add_host); - - extend_args(&mut args, "--arch", &self.arch); - - extend_args(&mut args, "--attach", &self.attach); - - let authfile = self.authfile.as_deref().map(Path::to_string_lossy); - extend_args(&mut args, "--authfile", &authfile); - - let blkio_weight = self.blkio_weight.map(|weight| weight.to_string()); - extend_args(&mut args, "--blkio-weight", &blkio_weight); - - extend_args( - &mut args, - "--blkio-weight-device", - &self.blkio_weight_device, - ); - - extend_args(&mut args, "--cgroup-conf", &self.cgroup_conf); - - let cgroup_parent = self.cgroup_parent.as_deref().map(Path::to_string_lossy); - extend_args(&mut args, "--cgroup-parent", &cgroup_parent); - - extend_args(&mut args, "--cgroupns", &self.cgroupns); - - extend_args(&mut args, "--cgroups", &self.cgroups); - - extend_args(&mut args, "--chrootdirs", &self.chrootdirs); - - let cidfile = self.cidfile.as_deref().map(Path::to_string_lossy); - extend_args(&mut args, "--cidfile", &cidfile); - - let conmon_pidfile = self.conmon_pidfile.as_deref().map(Path::to_string_lossy); - extend_args(&mut args, "--conmon-pidfile", &conmon_pidfile); - - let cpu_period = self.cpu_period.map(|period| period.to_string()); - extend_args(&mut args, "--cpu-period", &cpu_period); - - let cpu_quota = self.cpu_quota.map(|quota| quota.to_string()); - extend_args(&mut args, "--cpu-quota", &cpu_quota); - - let cpu_rt_period = self.cpu_rt_period.map(|period| period.to_string()); - extend_args(&mut args, "--cpu-rt-period", &cpu_rt_period); - - let cpu_rt_runtime = self.cpu_rt_runtime.map(|runtime| runtime.to_string()); - extend_args(&mut args, "--cpu-rt-runtime", &cpu_rt_runtime); - - let cpu_shares = self.cpu_shares.map(|shares| shares.to_string()); - extend_args(&mut args, "--cpu-shares", &cpu_shares); - - let cpus = self.cpus.map(|cpus| cpus.to_string()); - extend_args(&mut args, "--cpus", &cpus); - - extend_args(&mut args, "--cpuset-cpus", &self.cpuset_cpus); - - extend_args(&mut args, "--cpuset-mems", &self.cpuset_mems); - - extend_args(&mut args, "--decryption-key", &self.decryption_key); - - extend_args(&mut args, "--detach-keys", &self.detach_keys); - - extend_args(&mut args, "--device-cgroup-rule", &self.device_cgroup_rule); - - extend_args(&mut args, "--device-read-bps", &self.device_read_bps); - - extend_args(&mut args, "--device-read-iops", &self.device_read_iops); - - extend_args(&mut args, "--device-write-bps", &self.device_write_bps); - - extend_args(&mut args, "--device-write-iops", &self.device_write_iops); - - extend_args(&mut args, "--dns", &self.dns); - - extend_args(&mut args, "--dns-option", &self.dns_option); - - extend_args(&mut args, "--dns-search", &self.dns_search); - - extend_args(&mut args, "--entrypoint", &self.entrypoint); - - extend_args(&mut args, "--env-merge", &self.env_merge); - - extend_args(&mut args, "--gidmap", &self.gidmap); - - extend_args(&mut args, "--group-add", &self.group_add); - - extend_args(&mut args, "--group-entry", &self.group_entry); - - extend_args(&mut args, "--hostuser", &self.hostuser); - - if !self.http_proxy { - args.extend(["--http-proxy", "false"]); - } - - extend_args(&mut args, "--image-volume", &self.image_volume); - - let init_path = self.init_path.as_deref().map(Path::to_string_lossy); - extend_args(&mut args, "--init-path", &init_path); - - if self.interactive { - args.push("--interactive"); - } - - extend_args(&mut args, "--ipc", &self.ipc); - - let label_file = self.label_file.as_deref().map(Path::to_string_lossy); - extend_args(&mut args, "--label-file", &label_file); - - extend_args(&mut args, "--link-local-ip", &self.link_local_ip); - - extend_args(&mut args, "--log-opt", &self.log_opt); - - extend_args(&mut args, "--mac-address", &self.mac_address); - - extend_args(&mut args, "--memory", &self.memory); - - extend_args(&mut args, "--memory-reservation", &self.memory_reservation); - - extend_args(&mut args, "--memory-swap", &self.memory_swap); - - let memory_swappiness = self - .memory_swappiness - .map(|swappiness| swappiness.to_string()); - extend_args(&mut args, "--memory-swappiness", &memory_swappiness); - - extend_args(&mut args, "--network-alias", &self.network_alias); - - if self.no_healthcheck { - args.push("--no-healthcheck"); - } - - if self.no_hosts { - args.push("--no-hosts"); - } - - if self.oom_kill_disable { - args.push("--oom-kill-disable"); - } - - let oom_score_adj = self.oom_score_adj.map(|score| score.to_string()); - extend_args(&mut args, "--oom-score-adj", &oom_score_adj); - - extend_args(&mut args, "--os", &self.os); - - if self.passwd { - args.push("--passwd"); - } - - extend_args(&mut args, "--passwd-entry", &self.passwd_entry); - - extend_args(&mut args, "--personality", &self.personality); - - extend_args(&mut args, "--pid", &self.pid); - - let pidfile = self.pidfile.as_deref().map(Path::to_string_lossy); - extend_args(&mut args, "--pidfile", &pidfile); - - let pids_limit = self.pids_limit.map(|limit| limit.to_string()); - extend_args(&mut args, "--pids-limit", &pids_limit); - - extend_args(&mut args, "--platform", &self.platform); - - extend_args(&mut args, "--pod", &self.pod); - - let pod_id_file = self.pod_id_file.as_deref().map(Path::to_string_lossy); - extend_args(&mut args, "--pod-id-file", &pod_id_file); - - let preserve_fds = self.preserve_fds.map(|n| n.to_string()); - extend_args(&mut args, "--preserve-fds", &preserve_fds); - - if self.privileged { - args.push("--privileged"); - } - - if self.publish_all { - args.push("--publish-all"); - } - - if self.quiet { - args.push("--quiet"); - } - - if !self.read_only_tmpfs { - args.extend(["--read-only-tmpfs", "false"]); - } - - extend_args(&mut args, "--requires", &self.requires); - - if self.rmi { - args.push("--rmi"); - } - - extend_args(&mut args, "--seccomp-policy", &self.seccomp_policy); - - extend_args(&mut args, "--shm-size", &self.shm_size); - - extend_args(&mut args, "--shm-size-systemd", &self.shm_size_systemd); - - if !self.sig_proxy { - args.extend(["--sig-proxy", "false"]); - } - - extend_args(&mut args, "--stop-signal", &self.stop_signal); - - let stop_timeout = self.stop_timeout.map(|timeout| timeout.to_string()); - extend_args(&mut args, "--stop-timeout", &stop_timeout); - - extend_args(&mut args, "--subgidname", &self.subgidname); - - extend_args(&mut args, "--subuidname", &self.subuidname); - - extend_args(&mut args, "--systemd", &self.systemd); - - let timeout = self.timeout.map(|timeout| timeout.to_string()); - extend_args(&mut args, "--timeout", &timeout); - - let tls_verify = self.tls_verify.map(|verify| verify.to_string()); - extend_args(&mut args, "--tls-verify", &tls_verify); - - if self.tty { - args.push("--tty"); - } - - extend_args(&mut args, "--uidmap", &self.uidmap); - - extend_args(&mut args, "--ulimit", &self.ulimit); - - extend_args(&mut args, "--umask", &self.umask); - - extend_args(&mut args, "--volumes-from", &self.volumes_from); - - debug_assert_eq!(args.len(), self.args_len()); - - write!(f, "{}", shlex::join(args)) + let args = crate::serde::args::to_string(self).map_err(|_| fmt::Error)?; + f.write_str(&args) } } @@ -943,3 +707,14 @@ impl TryFrom<&mut docker_compose_types::Service> for PodmanArgs { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_display_empty() { + let args = PodmanArgs::default(); + assert!(args.to_string().is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs index 3b58c33..4c17488 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,9 @@ mod cli; mod quadlet; +mod serde { + pub mod args; +} use clap::Parser; use color_eyre::eyre; diff --git a/src/serde/args.rs b/src/serde/args.rs new file mode 100644 index 0000000..8a55fb2 --- /dev/null +++ b/src/serde/args.rs @@ -0,0 +1,741 @@ +use std::fmt::Display; + +use serde::{ + ser::{self, Impossible}, + Serialize, +}; +use thiserror::Error; + +/// Serializes a struct or map into arguments suitable for a shell. +/// +/// # Errors +/// +/// Returns an error if the value errors while serializing, the value is a non-serializable type, +/// the value has nested maps, or the value is a map without string keys. +/// +/// ``` +/// #[derive(Serialize)] +/// struct Example { +/// str: &'static str, +/// vec: Vec, +/// } +/// let example = Example { +/// str: "Hello world!", +/// vec: vec![1, 2], +/// }; +/// assert_eq!(to_string(example).unwrap(), "--str \"Hello world!\" --vec 1 --vec 2"); +/// ``` +pub fn to_string(value: T) -> Result { + let mut serializer = Serializer::default(); + value.serialize(&mut serializer)?; + Ok(serializer.output) +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum Error { + #[error("error while serializing args: {0}")] + Custom(String), + #[error("flag arg is missing")] + MissingFlag, + #[error("flag arg (map key or struct field) cannot be empty")] + EmptyFlag, + #[error("flags cannot be nested")] + NestedFlag, + #[error("map keys must a string")] + InvalidMapKeyType, + #[error("this type cannot be serialized")] + InvalidType, +} + +impl ser::Error for Error { + fn custom(msg: T) -> Self + where + T: Display, + { + Error::Custom(msg.to_string()) + } +} + +/// A serializer for converting structs or maps into a series of flags and arguments. +/// +/// Sequences are serialized by repeating the previous flag, saved in `current_flag`. +/// Values are responsible for adding the current flag as well as themselves to `output`. +/// `current_flag` is set when serializing map keys and struct fields. +#[derive(Default)] +struct Serializer { + output: String, + current_flag: Option>, +} + +impl Serializer { + /// Set the current flag. + /// + /// # Errors + /// + /// Returns an error if the given flag is empty or there is already a stored flag. + fn set_current_flag(&mut self, flag: impl Into>) -> Result<(), Error> { + let flag: Box = flag.into(); + if flag.is_empty() { + Err(Error::EmptyFlag) + } else if self.current_flag.is_some() { + Err(Error::NestedFlag) + } else { + self.current_flag = Some(flag); + Ok(()) + } + } + + /// Push the current flag into the `output`. + /// + /// # Errors + /// + /// Returns an error if there is not a current flag. + fn push_flag(&mut self) -> Result<(), Error> { + let flag = self.current_flag.as_deref().ok_or(Error::MissingFlag)?; + if !self.output.is_empty() { + self.output.push(' '); + } + self.output.push_str("--"); + self.output.push_str(flag); + Ok(()) + } + + /// Pushes a `value` into `output`, see [`Serializer::push_value()`]. + fn push_display_value(&mut self, value: impl Display) -> Result<(), Error> { + self.push_value(&value.to_string()) + } + + /// Pushes the current flag and a `value` into `output`. + /// + /// # Errors + /// + /// Returns an error if there is an error when pushing the current flag. + fn push_value(&mut self, value: &str) -> Result<(), Error> { + self.push_flag()?; + if !value.is_empty() { + self.output.push(' '); + self.output.push_str(value); + } + Ok(()) + } +} + +impl<'a> ser::Serializer for &'a mut Serializer { + type Ok = (); + + type Error = Error; + + type SerializeSeq = Self; + + type SerializeTuple = Self; + + type SerializeTupleStruct = Self; + + type SerializeTupleVariant = Self; + + type SerializeMap = Self; + + type SerializeStruct = Self; + + type SerializeStructVariant = Self; + + fn serialize_bool(self, v: bool) -> Result { + if v { + self.push_flag() + } else { + self.push_value("false") + } + } + + fn serialize_i8(self, v: i8) -> Result { + self.push_display_value(v) + } + + fn serialize_i16(self, v: i16) -> Result { + self.push_display_value(v) + } + + fn serialize_i32(self, v: i32) -> Result { + self.push_display_value(v) + } + + fn serialize_i64(self, v: i64) -> Result { + self.push_display_value(v) + } + + fn serialize_u8(self, v: u8) -> Result { + self.push_display_value(v) + } + + fn serialize_u16(self, v: u16) -> Result { + self.push_display_value(v) + } + + fn serialize_u32(self, v: u32) -> Result { + self.push_display_value(v) + } + + fn serialize_u64(self, v: u64) -> Result { + self.push_display_value(v) + } + + fn serialize_f32(self, v: f32) -> Result { + self.push_display_value(v) + } + + fn serialize_f64(self, v: f64) -> Result { + self.push_display_value(v) + } + + fn serialize_char(self, v: char) -> Result { + self.push_flag()?; + self.output.push(' '); + self.output.push(v); + Ok(()) + } + + fn serialize_str(self, v: &str) -> Result { + self.push_value(&shlex::quote(v)) + } + + fn serialize_bytes(self, _: &[u8]) -> Result { + Err(Error::InvalidType) + } + + fn serialize_none(self) -> Result { + Ok(()) + } + + fn serialize_some(self, value: &T) -> Result + where + T: Serialize, + { + value.serialize(self) + } + + fn serialize_unit(self) -> Result { + Ok(()) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result { + Ok(()) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + ) -> Result { + self.serialize_str(variant) + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + value: &T, + ) -> Result + where + T: Serialize, + { + value.serialize(self) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + value: &T, + ) -> Result + where + T: Serialize, + { + value.serialize(self) + } + + fn serialize_seq(self, _len: Option) -> Result { + Ok(self) + } + + fn serialize_tuple(self, _len: usize) -> Result { + Ok(self) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + Ok(self) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Ok(self) + } + + fn serialize_map(self, _len: Option) -> Result { + Ok(self) + } + + fn serialize_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + Ok(self) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Ok(self) + } + + fn serialize_i128(self, v: i128) -> Result { + self.push_display_value(v) + } + + fn serialize_u128(self, v: u128) -> Result { + self.push_display_value(v) + } +} + +impl ser::SerializeSeq for &mut Serializer { + type Ok = (); + + type Error = Error; + + fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + value.serialize(&mut **self) + } + + fn end(self) -> Result { + self.current_flag = None; + Ok(()) + } +} + +impl ser::SerializeTuple for &mut Serializer { + type Ok = (); + + type Error = Error; + + fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result { + ser::SerializeSeq::end(self) + } +} + +impl ser::SerializeTupleStruct for &mut Serializer { + type Ok = (); + + type Error = Error; + + fn serialize_field(&mut self, value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result { + ser::SerializeSeq::end(self) + } +} + +impl ser::SerializeTupleVariant for &mut Serializer { + type Ok = (); + + type Error = Error; + + fn serialize_field(&mut self, value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result { + ser::SerializeSeq::end(self) + } +} + +impl ser::SerializeMap for &mut Serializer { + type Ok = (); + + type Error = Error; + + fn serialize_key(&mut self, key: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + key.serialize(MapKeySerializer(self)) + } + + fn serialize_value(&mut self, value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + value.serialize(&mut **self)?; + self.current_flag = None; + Ok(()) + } + + fn end(self) -> Result { + Ok(()) + } +} + +struct MapKeySerializer<'a>(&'a mut Serializer); + +impl<'a> ser::Serializer for MapKeySerializer<'a> { + type Ok = (); + + type Error = Error; + + type SerializeSeq = Impossible<(), Error>; + + type SerializeTuple = Impossible<(), Error>; + + type SerializeTupleStruct = Impossible<(), Error>; + + type SerializeTupleVariant = Impossible<(), Error>; + + type SerializeMap = Impossible<(), Error>; + + type SerializeStruct = Impossible<(), Error>; + + type SerializeStructVariant = Impossible<(), Error>; + + fn serialize_bool(self, _: bool) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_i8(self, _: i8) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_i16(self, _: i16) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_i32(self, _: i32) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_i64(self, _: i64) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_u8(self, _: u8) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_u16(self, _: u16) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_u32(self, _: u32) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_u64(self, _: u64) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_f32(self, _: f32) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_f64(self, _: f64) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_char(self, _: char) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_str(self, v: &str) -> Result { + self.0.set_current_flag(v) + } + + fn serialize_bytes(self, _: &[u8]) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_none(self) -> Result { + Err(Error::EmptyFlag) + } + + fn serialize_some(self, value: &T) -> Result + where + T: Serialize, + { + value.serialize(self) + } + + fn serialize_unit(self) -> Result { + Err(Error::EmptyFlag) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + ) -> Result { + self.serialize_str(variant) + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + value: &T, + ) -> Result + where + T: Serialize, + { + value.serialize(self) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + value: &T, + ) -> Result + where + T: Serialize, + { + value.serialize(self) + } + + fn serialize_seq(self, _len: Option) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_tuple(self, _len: usize) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_map(self, _len: Option) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + Err(Error::InvalidMapKeyType) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(Error::InvalidMapKeyType) + } +} + +impl ser::SerializeStruct for &mut Serializer { + type Ok = (); + + type Error = Error; + + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> + where + T: Serialize, + { + self.set_current_flag(key)?; + value.serialize(&mut **self)?; + self.current_flag = None; + Ok(()) + } + + fn end(self) -> Result { + Ok(()) + } +} + +impl ser::SerializeStructVariant for &mut Serializer { + type Ok = (); + + type Error = Error; + + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> + where + T: Serialize, + { + ser::SerializeStruct::serialize_field(self, key, value) + } + + fn end(self) -> Result { + ser::SerializeStruct::end(self) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use indexmap::IndexMap; + + use super::*; + + #[test] + fn basic_struct() { + #[derive(Serialize)] + #[serde(rename_all = "kebab-case")] + struct Test { + option_one: &'static str, + two: u8, + } + + let sut = Test { + option_one: "one", + two: 2, + }; + assert_eq!(to_string(sut).unwrap(), "--option-one one --two 2"); + } + + #[test] + fn struct_with_sequence() { + #[derive(Serialize)] + struct Test { + tuple: (u16, u32, u64), + array: [char; 3], + vec: Vec<&'static str>, + } + + let sut = Test { + tuple: (1, 2, 3), + array: ['a', 'b', 'c'], + vec: vec!["one", "two", "three"], + }; + assert_eq!( + to_string(sut).unwrap(), + "--tuple 1 --tuple 2 --tuple 3 \ + --array a --array b --array c \ + --vec one --vec two --vec three" + ); + } + + #[test] + fn map() { + let map = IndexMap::from([("one", "1"), ("two", "2")]); + assert_eq!(to_string(map).unwrap(), "--one 1 --two 2"); + + let map = IndexMap::from([("one", ["1", "2"]), ("two", ["3", "4"])]); + assert_eq!(to_string(map).unwrap(), "--one 1 --one 2 --two 3 --two 4"); + } + + #[test] + fn escape_values() { + let map = IndexMap::from([("one", "Hello, world!")]); + assert_eq!(to_string(map).unwrap(), r#"--one "Hello, world!""#); + } + + #[test] + fn bool() { + #[derive(Serialize)] + struct Test { + yes: bool, + no: bool, + } + + let sut = Test { + yes: true, + no: false, + }; + assert_eq!(to_string(sut).unwrap(), "--yes --no false"); + } + + #[test] + fn enum_value() { + #[derive(Serialize)] + #[serde(rename_all = "kebab-case")] + enum Enum { + One, + Two, + } + + #[derive(Serialize)] + struct Test { + one: Enum, + two: Enum, + } + + let sut = Test { + one: Enum::One, + two: Enum::Two, + }; + + assert_eq!(to_string(sut).unwrap(), "--one one --two two"); + } + + #[test] + fn nested_err() { + #[derive(Serialize)] + struct Test { + map: IndexMap<&'static str, &'static str>, + } + let sut = Test { + map: IndexMap::from([("one", "1"), ("two", "2")]), + }; + assert_eq!(to_string(sut).unwrap_err(), Error::NestedFlag); + } +} From ad641cce026717663fc1abd7c594672aa4b1d8e0 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Tue, 5 Dec 2023 18:12:33 -0600 Subject: [PATCH 07/23] fix(container): arg `--tls-verify` requires = Bools are now serialized as `--flag` or `--flag=false`. --- src/cli/container/podman.rs | 2 +- src/serde/args.rs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cli/container/podman.rs b/src/cli/container/podman.rs index ccb5bd3..8dd1046 100644 --- a/src/cli/container/podman.rs +++ b/src/cli/container/podman.rs @@ -480,7 +480,7 @@ pub struct PodmanArgs { timeout: Option, /// Require HTTPS and verify certificates when contacting registries - #[arg(long)] + #[arg(long, num_args = 0..=1, require_equals = true, default_missing_value = "true")] #[serde(skip_serializing_if = "Option::is_none")] tls_verify: Option, diff --git a/src/serde/args.rs b/src/serde/args.rs index 8a55fb2..ab9ca35 100644 --- a/src/serde/args.rs +++ b/src/serde/args.rs @@ -143,7 +143,9 @@ impl<'a> ser::Serializer for &'a mut Serializer { if v { self.push_flag() } else { - self.push_value("false") + self.push_flag()?; + self.output.push_str("=false"); + Ok(()) } } @@ -701,7 +703,7 @@ mod tests { yes: true, no: false, }; - assert_eq!(to_string(sut).unwrap(), "--yes --no false"); + assert_eq!(to_string(sut).unwrap(), "--yes --no=false"); } #[test] From 0da72ab0643e9fd5d783e2f602080aad39dd7f2a Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Tue, 5 Dec 2023 19:09:00 -0600 Subject: [PATCH 08/23] feat(kube): add `PodmanArgs=` quadlet option --- src/cli.rs | 4 +-- src/cli/kube.rs | 84 +++++++++++++++++++++++++++++++++++++++++++++ src/quadlet/kube.rs | 6 ++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 8e614ef..f00e63a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -336,7 +336,7 @@ enum PodmanCommands { Kube { /// The \[Kube\] section #[command(subcommand)] - kube: Kube, + kube: Box, }, /// Generate a podman quadlet `.network` file @@ -376,7 +376,7 @@ impl From for quadlet::Resource { fn from(value: PodmanCommands) -> Self { match value { PodmanCommands::Run { container, .. } => (*container).into(), - PodmanCommands::Kube { kube } => kube.into(), + PodmanCommands::Kube { kube } => (*kube).into(), PodmanCommands::Network { network } => network.into(), PodmanCommands::Volume { volume } => volume.into(), } diff --git a/src/cli/kube.rs b/src/cli/kube.rs index cfeb9ee..47bcd0e 100644 --- a/src/cli/kube.rs +++ b/src/cli/kube.rs @@ -2,11 +2,14 @@ use std::{ convert::Infallible, ffi::OsStr, fmt::{self, Display, Formatter}, + net::IpAddr, + ops::Not, path::PathBuf, str::FromStr, }; use clap::{Args, Subcommand}; +use serde::Serialize; use url::Url; #[derive(Subcommand, Debug, Clone, PartialEq)] @@ -84,6 +87,10 @@ pub struct Play { #[arg(long, value_name = "MODE")] userns: Option, + /// Converts to "PodmanArgs=ARGS" + #[command(flatten)] + podman_args: PodmanArgs, + /// The path to the Kubernetes YAML file to use /// /// Converts to "Yaml=FILE" @@ -92,10 +99,12 @@ pub struct Play { impl From for crate::quadlet::Kube { fn from(value: Play) -> Self { + let podman_args = value.podman_args.to_string(); Self { config_map: value.configmap, log_driver: value.log_driver, network: value.network, + podman_args: (!podman_args.is_empty()).then_some(podman_args), publish_port: value.publish, user_ns: value.userns, yaml: value.file.to_string(), @@ -103,6 +112,81 @@ impl From for crate::quadlet::Kube { } } +#[derive(Args, Serialize, Debug, Clone, PartialEq)] +pub struct PodmanArgs { + /// Add an annotation to the container or pod + /// + /// Can be specified multiple times + #[arg(long, value_name = "KEY=VALUE")] + #[serde(skip_serializing_if = "Vec::is_empty")] + annotation: Vec, + + /// Build images even if they are found in the local storage + /// + /// Use `--build=false` to completely disable builds + #[arg(long, num_args = 0..=1, require_equals = true, default_missing_value = "true")] + #[serde(skip_serializing_if = "Option::is_none")] + build: Option, + + /// Use certificates at `path` (*.crt, *.cert, *.key) to connect to the registry + #[arg(long, value_name = "PATH")] + #[serde(skip_serializing_if = "Option::is_none")] + cert_dir: Option, + + /// Use `path` as the build context directory for each image + #[arg(long, requires = "build", value_name = "PATH")] + #[serde(skip_serializing_if = "Option::is_none")] + context_dir: Option, + + /// The username and password to use to authenticate with the registry, if required + #[arg(long, value_name = "USERNAME[:PASSWORD]")] + #[serde(skip_serializing_if = "Option::is_none")] + creds: Option, + + /// Assign a static ip address to the pod + /// + /// Can be specified multiple times + #[arg(long)] + #[serde(skip_serializing_if = "Vec::is_empty")] + ip: Vec, + + /// Logging driver specific options + /// + /// Can be specified multiple times + #[arg(long, value_name = "NAME=VALUE")] + #[serde(skip_serializing_if = "Vec::is_empty")] + log_opt: Vec, + + /// Assign a static mac address to the pod + /// + /// Can be specified multiple times + #[arg(long)] + #[serde(skip_serializing_if = "Vec::is_empty")] + mac_address: Vec, + + /// Do not create `/etc/hosts` for the pod + #[arg(long)] + #[serde(skip_serializing_if = "Not::not")] + no_hosts: bool, + + /// Directory path for seccomp profiles + #[arg(long, value_name = "PATH")] + #[serde(skip_serializing_if = "Option::is_none")] + seccomp_profile_root: Option, + + /// Require HTTPS and verify certificates when contacting registries + #[arg(long, num_args = 0..=1, require_equals = true, default_missing_value = "true")] + #[serde(skip_serializing_if = "Option::is_none")] + tls_verify: Option, +} + +impl Display for PodmanArgs { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let args = crate::serde::args::to_string(self).map_err(|_| fmt::Error)?; + f.write_str(&args) + } +} + #[derive(Debug, Clone, PartialEq)] enum File { Url(Url), diff --git a/src/quadlet/kube.rs b/src/quadlet/kube.rs index 026443f..92a2abc 100644 --- a/src/quadlet/kube.rs +++ b/src/quadlet/kube.rs @@ -8,6 +8,7 @@ pub struct Kube { pub config_map: Vec, pub log_driver: Option, pub network: Vec, + pub podman_args: Option, pub publish_port: Vec, pub user_ns: Option, pub yaml: String, @@ -19,6 +20,7 @@ impl Kube { config_map: Vec::new(), log_driver: None, network: Vec::new(), + podman_args: None, publish_port: Vec::new(), user_ns: None, yaml, @@ -44,6 +46,10 @@ impl Display for Kube { writeln!(f, "Network={network}")?; } + if let Some(podman_args) = &self.podman_args { + writeln!(f, "PodmanArgs={podman_args}")?; + } + for port in &self.publish_port { writeln!(f, "PublishPort={port}")?; } From dda9e93f07cf76b3d37208cd4edb8e8eba1ebe6d Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Wed, 6 Dec 2023 00:56:19 -0600 Subject: [PATCH 09/23] feat(container): add `Mask=` quadlet option Made `quadlet::writeln_escape_spaces()` take a generic const parameter for the separator used for joining the words. --- src/cli/container.rs | 2 ++ src/cli/container/security_opt.rs | 3 ++- src/quadlet.rs | 8 +++++--- src/quadlet/container.rs | 14 ++++++++++---- src/quadlet/install.rs | 4 ++-- src/quadlet/network.rs | 2 +- src/quadlet/volume.rs | 2 +- 7 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/cli/container.rs b/src/cli/container.rs index 8751a0f..b64c3f6 100644 --- a/src/cli/container.rs +++ b/src/cli/container.rs @@ -111,6 +111,7 @@ impl From for crate::quadlet::Container { let mut podman_args = podman_args.to_string(); let security_opt::QuadletOptions { + mask, no_new_privileges, seccomp_profile, security_label_disable, @@ -134,6 +135,7 @@ impl From for crate::quadlet::Container { Self { image, + mask, no_new_privileges, seccomp_profile, security_label_disable, diff --git a/src/cli/container/security_opt.rs b/src/cli/container/security_opt.rs index 4296e08..fc2c162 100644 --- a/src/cli/container/security_opt.rs +++ b/src/cli/container/security_opt.rs @@ -105,6 +105,7 @@ pub struct InvalidLabelOpt(pub String); #[derive(Debug, Default, Clone, PartialEq)] pub struct QuadletOptions { + pub mask: Vec, pub no_new_privileges: bool, pub seccomp_profile: Option, pub security_label_disable: bool, @@ -120,7 +121,7 @@ impl QuadletOptions { match security_opt { SecurityOpt::Apparmor(policy) => self.podman_args.push(format!("apparmor={policy}")), SecurityOpt::Label(label_opt) => self.add_label_opt(label_opt), - SecurityOpt::Mask(mask) => self.podman_args.push(format!("mask={mask}")), + SecurityOpt::Mask(mask) => self.mask.extend(mask.split(':').map(Into::into)), SecurityOpt::NoNewPrivileges => self.no_new_privileges = true, SecurityOpt::Seccomp(profile) => self.seccomp_profile = Some(profile), SecurityOpt::ProcOpts(proc_opts) => { diff --git a/src/quadlet.rs b/src/quadlet.rs index 2db4e32..907d2f8 100644 --- a/src/quadlet.rs +++ b/src/quadlet.rs @@ -192,7 +192,9 @@ impl FromStr for AutoUpdate { #[error("unknown auto update variant `{0}`, must be `registry` or `local`")] pub struct ParseAutoUpdateError(String); -fn writeln_escape_spaces(f: &mut Formatter, key: &str, words: I) -> fmt::Result +/// Writes the line `key=joined_words` to the [`Formatter`]. `joined_words` is the given `words`, +/// where each word is escaped and joined together with the const parameter `C` as a separator. +fn writeln_escape_spaces(f: &mut Formatter, key: &str, words: I) -> fmt::Result where I: IntoIterator, I::Item: AsRef, @@ -206,7 +208,7 @@ where } for word in words { - f.write_char(' ')?; + f.write_char(C)?; escape_spaces(f, word.as_ref())?; } @@ -231,7 +233,7 @@ mod tests { impl Display for Foo { fn fmt(&self, f: &mut Formatter) -> fmt::Result { - writeln_escape_spaces(f, "Foo", self.0) + writeln_escape_spaces::<' ', _>(f, "Foo", self.0) } } diff --git a/src/quadlet/container.rs b/src/quadlet/container.rs index 26b1c50..782516b 100644 --- a/src/quadlet/container.rs +++ b/src/quadlet/container.rs @@ -40,6 +40,7 @@ pub struct Container { pub ip6: Option, pub label: Vec, pub log_driver: Option, + pub mask: Vec, pub mount: Vec, pub network: Vec, pub no_new_privileges: bool, @@ -83,7 +84,7 @@ impl Display for Container { } if !self.annotation.is_empty() { - writeln_escape_spaces(f, "Annotation", &self.annotation)?; + writeln_escape_spaces::<' ', _>(f, "Annotation", &self.annotation)?; } if let Some(auto_update) = self.auto_update { @@ -99,7 +100,7 @@ impl Display for Container { } if !self.environment.is_empty() { - writeln_escape_spaces(f, "Environment", &self.environment)?; + writeln_escape_spaces::<' ', _>(f, "Environment", &self.environment)?; } for file in &self.environment_file { @@ -175,13 +176,18 @@ impl Display for Container { } if !self.label.is_empty() { - writeln_escape_spaces(f, "Label", &self.label)?; + writeln_escape_spaces::<' ', _>(f, "Label", &self.label)?; } if let Some(log_driver) = &self.log_driver { writeln!(f, "LogDriver={log_driver}")?; } + // each mask item is a separate path, to be escaped and joined + if !self.mask.is_empty() { + writeln_escape_spaces::<':', _>(f, "Mask", &self.mask)?; + } + for mount in &self.mount { writeln!(f, "Mount={mount}")?; } @@ -247,7 +253,7 @@ impl Display for Container { } if !self.sysctl.is_empty() { - writeln_escape_spaces(f, "Sysctl", &self.sysctl)?; + writeln_escape_spaces::<' ', _>(f, "Sysctl", &self.sysctl)?; } for tmpfs in &self.tmpfs { diff --git a/src/quadlet/install.rs b/src/quadlet/install.rs index 6a974ad..83b9913 100644 --- a/src/quadlet/install.rs +++ b/src/quadlet/install.rs @@ -13,11 +13,11 @@ impl Display for Install { writeln!(f, "[Install]")?; if !self.wanted_by.is_empty() { - writeln_escape_spaces(f, "WantedBy", &self.wanted_by)?; + writeln_escape_spaces::<' ', _>(f, "WantedBy", &self.wanted_by)?; } if !self.required_by.is_empty() { - writeln_escape_spaces(f, "RequiredBy", &self.required_by)?; + writeln_escape_spaces::<' ', _>(f, "RequiredBy", &self.required_by)?; } Ok(()) diff --git a/src/quadlet/network.rs b/src/quadlet/network.rs index 8eb51b7..f30e4bc 100644 --- a/src/quadlet/network.rs +++ b/src/quadlet/network.rs @@ -120,7 +120,7 @@ impl Display for Network { } if !self.label.is_empty() { - writeln_escape_spaces(f, "Label", &self.label)?; + writeln_escape_spaces::<' ', _>(f, "Label", &self.label)?; } if let Some(options) = &self.options { diff --git a/src/quadlet/volume.rs b/src/quadlet/volume.rs index cbe7925..47a48f7 100644 --- a/src/quadlet/volume.rs +++ b/src/quadlet/volume.rs @@ -78,7 +78,7 @@ impl Display for Volume { } if !self.label.is_empty() { - writeln_escape_spaces(f, "Label", &self.label)?; + writeln_escape_spaces::<' ', _>(f, "Label", &self.label)?; } if let Some(options) = &self.options { From 97e74540248f0fc4a8e78bdf17e97aa9832a16cb Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Wed, 6 Dec 2023 03:13:09 -0600 Subject: [PATCH 10/23] feat(container): add `Unmask=` quadlet option --- src/cli/container.rs | 2 + src/cli/container/security_opt.rs | 8 ++- src/quadlet.rs | 2 +- src/quadlet/container.rs | 113 ++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/cli/container.rs b/src/cli/container.rs index b64c3f6..a76c81c 100644 --- a/src/cli/container.rs +++ b/src/cli/container.rs @@ -119,6 +119,7 @@ impl From for crate::quadlet::Container { security_label_level, security_label_nested, security_label_type, + unmask, podman_args: security_podman_args, } = security_opt.into_iter().fold( security_opt::QuadletOptions::default(), @@ -143,6 +144,7 @@ impl From for crate::quadlet::Container { security_label_level, security_label_nested, security_label_type, + unmask, podman_args: (!podman_args.is_empty()).then(|| podman_args.trim().to_string()), exec: (!command.is_empty()).then(|| shlex::join(command.iter().map(String::as_str))), ..quadlet_options.into() diff --git a/src/cli/container/security_opt.rs b/src/cli/container/security_opt.rs index fc2c162..460b80a 100644 --- a/src/cli/container/security_opt.rs +++ b/src/cli/container/security_opt.rs @@ -2,6 +2,8 @@ use std::str::FromStr; use thiserror::Error; +use crate::quadlet::Unmask; + #[derive(Debug, Clone, PartialEq)] pub enum SecurityOpt { Apparmor(String), @@ -113,6 +115,7 @@ pub struct QuadletOptions { pub security_label_level: Option, pub security_label_nested: bool, pub security_label_type: Option, + pub unmask: Option, pub podman_args: Vec, } @@ -127,7 +130,10 @@ impl QuadletOptions { SecurityOpt::ProcOpts(proc_opts) => { self.podman_args.push(format!("proc-opts={proc_opts}")); } - SecurityOpt::Unmask(unmask) => self.podman_args.push(format!("unmask={unmask}")), + SecurityOpt::Unmask(paths) => self + .unmask + .get_or_insert_with(Unmask::new) + .extend(paths.split(':')), } } diff --git a/src/quadlet.rs b/src/quadlet.rs index 907d2f8..2c3594f 100644 --- a/src/quadlet.rs +++ b/src/quadlet.rs @@ -12,7 +12,7 @@ use std::{ use thiserror::Error; pub use self::{ - container::{Container, PullPolicy}, + container::{Container, PullPolicy, Unmask}, install::Install, kube::Kube, network::Network, diff --git a/src/quadlet/container.rs b/src/quadlet/container.rs index 782516b..7b2dc03 100644 --- a/src/quadlet/container.rs +++ b/src/quadlet/container.rs @@ -1,5 +1,6 @@ use std::{ fmt::{self, Display, Formatter}, + iter, net::{Ipv4Addr, Ipv6Addr}, path::PathBuf, }; @@ -61,6 +62,7 @@ pub struct Container { pub sysctl: Vec, pub tmpfs: Vec, pub timezone: Option, + pub unmask: Option, pub user: Option, pub user_ns: Option, pub volatile_tmp: bool, @@ -264,6 +266,10 @@ impl Display for Container { writeln!(f, "Timezone={timezone}")?; } + if let Some(unmask) = &self.unmask { + writeln_escape_spaces::<':', _>(f, "Unmask", unmask)?; + } + if let Some(user) = &self.user { writeln!(f, "User={user}")?; } @@ -327,3 +333,110 @@ impl Display for PullPolicy { f.write_str(self.as_ref()) } } + +/// Options for the `Unmask=` quadlet option. +#[derive(Debug, Clone, PartialEq)] +pub enum Unmask { + All, + Paths(Vec), +} + +impl Unmask { + /// Create a new [`Unmask`]. + pub fn new() -> Self { + Self::Paths(Vec::new()) + } + + /// Add a path to the unmask list. + /// + /// If the path is `ALL`, the unmask list will always be `ALL`. + pub fn add_path(&mut self, path: impl Into) { + match self { + Unmask::All => {} + Unmask::Paths(paths) => { + let path: String = path.into(); + if path.to_lowercase() == "all" { + *self = Self::All; + } else { + paths.push(path); + } + } + } + } +} + +impl Default for Unmask { + fn default() -> Self { + Self::new() + } +} + +impl> Extend for Unmask { + fn extend>(&mut self, iter: T) { + for path in iter { + self.add_path(path); + } + } +} + +impl<'a> IntoIterator for &'a Unmask { + type Item = &'a str; + + type IntoIter = UnmaskIter<'a>; + + fn into_iter(self) -> Self::IntoIter { + match self { + Unmask::All => UnmaskIter::All(iter::once("ALL")), + Unmask::Paths(paths) => UnmaskIter::Paths(paths.iter()), + } + } +} + +/// Iterator for [`Unmask`]. +pub enum UnmaskIter<'a> { + All(iter::Once<&'a str>), + Paths(std::slice::Iter<'a, String>), +} + +impl<'a> Iterator for UnmaskIter<'a> { + type Item = &'a str; + + fn next(&mut self) -> Option { + match self { + Self::All(once) => once.next(), + Self::Paths(iter) => iter.next().map(String::as_str), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod unmask { + use super::*; + + #[test] + fn add_path() { + let mut unmask = Unmask::new(); + + unmask.add_path("/1"); + assert_eq!(unmask, Unmask::Paths(vec![String::from("/1")])); + + unmask.add_path("ALL"); + assert_eq!(unmask, Unmask::All); + + unmask.add_path("/2"); + assert_eq!(unmask, Unmask::All); + } + + #[test] + fn iter() { + let unmask = Unmask::Paths(vec![String::from("/1"), String::from("/2")]); + assert_eq!(unmask.into_iter().collect::>(), ["/1", "/2"]); + + let unmask = Unmask::All; + assert_eq!(unmask.into_iter().collect::>(), ["ALL"]); + } + } +} From cd47003b610e6688f7607248b7d71d30963cc62d Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Thu, 7 Dec 2023 00:07:04 -0600 Subject: [PATCH 11/23] feat(network): add `PodmanArgs=` quadlet option --- src/cli.rs | 4 ++-- src/cli/network.rs | 41 ++++++++++++++++++++++++++++++++++++++++- src/quadlet/network.rs | 5 +++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index f00e63a..11f16f9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -346,7 +346,7 @@ enum PodmanCommands { Network { /// The \[Network\] section #[command(subcommand)] - network: Network, + network: Box, }, /// Generate a podman quadlet `.volume` file @@ -377,7 +377,7 @@ impl From for quadlet::Resource { match value { PodmanCommands::Run { container, .. } => (*container).into(), PodmanCommands::Kube { kube } => (*kube).into(), - PodmanCommands::Network { network } => network.into(), + PodmanCommands::Network { network } => (*network).into(), PodmanCommands::Volume { volume } => volume.into(), } } diff --git a/src/cli/network.rs b/src/cli/network.rs index 4ca5b25..dcd1db2 100644 --- a/src/cli/network.rs +++ b/src/cli/network.rs @@ -1,7 +1,11 @@ -use std::net::IpAddr; +use std::{ + fmt::{self, Display, Formatter}, + net::IpAddr, +}; use clap::{Args, Subcommand}; use ipnet::IpNet; +use serde::Serialize; #[derive(Subcommand, Debug, Clone, PartialEq)] pub enum Network { @@ -111,6 +115,10 @@ pub struct Create { #[arg(long)] subnet: Vec, + /// Converts to "PodmanArgs=ARGS" + #[command(flatten)] + podman_args: PodmanArgs, + /// The name of the network to create /// /// This will be used as the name of the generated file when used with @@ -120,6 +128,7 @@ pub struct Create { impl From for crate::quadlet::Network { fn from(value: Create) -> Self { + let podman_args = value.podman_args.to_string(); Self { disable_dns: value.disable_dns, driver: value.driver, @@ -130,7 +139,37 @@ impl From for crate::quadlet::Network { ipv6: value.ipv6, label: value.label, options: Some(value.opt.join(",")), + podman_args: (!podman_args.is_empty()).then_some(podman_args), subnet: value.subnet, } } } + +#[derive(Args, Serialize, Debug, Clone, PartialEq)] +struct PodmanArgs { + /// Set network-scoped DNS resolver/nameserver for containers in this network + /// + /// Can be specified multiple times + #[arg(long, value_name = "IP")] + #[serde(skip_serializing_if = "Vec::is_empty")] + dns: Vec, + + /// Maps to the `network_interface` option in the network config + #[arg(long, value_name = "NAME")] + #[serde(skip_serializing_if = "Option::is_none")] + interface_name: Option, + + /// A static route to add to every container in this network + /// + /// Can be specified multiple times + #[arg(long)] + #[serde(skip_serializing_if = "Vec::is_empty")] + route: Vec, +} + +impl Display for PodmanArgs { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + let args = crate::serde::args::to_string(self).map_err(|_| fmt::Error)?; + f.write_str(&args) + } +} diff --git a/src/quadlet/network.rs b/src/quadlet/network.rs index f30e4bc..22dfd85 100644 --- a/src/quadlet/network.rs +++ b/src/quadlet/network.rs @@ -19,6 +19,7 @@ pub struct Network { pub ipv6: bool, pub label: Vec, pub options: Option, + pub podman_args: Option, pub subnet: Vec, } @@ -127,6 +128,10 @@ impl Display for Network { writeln!(f, "Options={options}")?; } + if let Some(podman_args) = &self.podman_args { + writeln!(f, "PodmanArgs={podman_args}")?; + } + for subnet in &self.subnet { writeln!(f, "Subnet={subnet}")?; } From 30b49517b11a7871085245648357a79d9e23d62c Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Thu, 7 Dec 2023 00:10:13 -0600 Subject: [PATCH 12/23] fix(network): filter out empty `Options=` quadlet option --- src/cli/network.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/network.rs b/src/cli/network.rs index dcd1db2..8b72411 100644 --- a/src/cli/network.rs +++ b/src/cli/network.rs @@ -138,7 +138,7 @@ impl From for crate::quadlet::Network { ip_range: value.ip_range, ipv6: value.ipv6, label: value.label, - options: Some(value.opt.join(",")), + options: (!value.opt.is_empty()).then(|| value.opt.join(",")), podman_args: (!podman_args.is_empty()).then_some(podman_args), subnet: value.subnet, } From f3be90fbc768859ecd81c760198599b244447700 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Thu, 7 Dec 2023 00:20:40 -0600 Subject: [PATCH 13/23] feat(volume): add `PodmanArgs=` quadlet option --- src/cli/volume.rs | 7 +++++++ src/quadlet/volume.rs | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/src/cli/volume.rs b/src/cli/volume.rs index 6a03b1e..bc249d5 100644 --- a/src/cli/volume.rs +++ b/src/cli/volume.rs @@ -42,6 +42,12 @@ impl Volume { #[derive(Args, Debug, Clone, PartialEq)] pub struct Create { + /// Specify the volume driver name + /// + /// Converts to "PodmanArgs=--driver DRIVER" + #[arg(short, long)] + driver: Option, + /// Set driver specific options /// /// "copy" converts to "Copy=true" @@ -79,6 +85,7 @@ impl From for crate::quadlet::Volume { fn from(value: Create) -> Self { Self { label: value.label, + podman_args: value.driver.map(|driver| format!("--driver {driver}")), ..value.opt.into() } } diff --git a/src/quadlet/volume.rs b/src/quadlet/volume.rs index 47a48f7..76e1b2c 100644 --- a/src/quadlet/volume.rs +++ b/src/quadlet/volume.rs @@ -13,6 +13,7 @@ pub struct Volume { pub group: Option, pub label: Vec, pub options: Option, + pub podman_args: Option, pub fs_type: Option, pub user: Option, } @@ -85,6 +86,10 @@ impl Display for Volume { writeln!(f, "Options={options}")?; } + if let Some(podman_args) = &self.podman_args { + writeln!(f, "PodmanArgs={podman_args}")?; + } + if let Some(fs_type) = &self.fs_type { writeln!(f, "Type={fs_type}")?; } From dd12834f8ea7cc3472835b127cc98047b9ce587e Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Sat, 9 Dec 2023 03:19:01 -0600 Subject: [PATCH 14/23] refactor: use custom serializer for quadlet sections Also added doc comments to quadlet fields with first sentence from the quadlet documentation. --- Cargo.lock | 3 + Cargo.toml | 2 +- src/main.rs | 1 + src/quadlet.rs | 65 +--- src/quadlet/container.rs | 412 +++++++++++-------------- src/quadlet/install.rs | 31 +- src/quadlet/kube.rs | 59 ++-- src/quadlet/network.rs | 94 +++--- src/quadlet/volume.rs | 80 ++--- src/serde/quadlet.rs | 650 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 985 insertions(+), 412 deletions(-) create mode 100644 src/serde/quadlet.rs diff --git a/Cargo.lock b/Cargo.lock index 44225a7..84c527d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -871,6 +871,9 @@ name = "ipnet" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +dependencies = [ + "serde", +] [[package]] name = "itoa" diff --git a/Cargo.toml b/Cargo.toml index e226f50..cd7d5e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ color-eyre = "0.6" docker-compose-types = "0.6.1" duration-str = { version = "0.7", default-features = false } indexmap = "2" -ipnet = "2.7" +ipnet = { version = "2.7", features = ["serde"] } k8s-openapi = { version = "0.20", features = ["latest"] } serde = { version = "1", features = ["derive"] } serde_yaml = "0.9.21" diff --git a/src/main.rs b/src/main.rs index 4c17488..9b4162c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod cli; mod quadlet; mod serde { pub mod args; + pub mod quadlet; } use clap::Parser; diff --git a/src/quadlet.rs b/src/quadlet.rs index 2c3594f..ee6f92d 100644 --- a/src/quadlet.rs +++ b/src/quadlet.rs @@ -5,10 +5,11 @@ mod network; mod volume; use std::{ - fmt::{self, Display, Formatter, Write}, + fmt::{self, Display, Formatter}, str::FromStr, }; +use serde::{Serialize, Serializer}; use thiserror::Error; pub use self::{ @@ -174,6 +175,12 @@ impl Display for AutoUpdate { } } +impl Serialize for AutoUpdate { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_ref()) + } +} + impl FromStr for AutoUpdate { type Err = ParseAutoUpdateError; @@ -191,59 +198,3 @@ impl FromStr for AutoUpdate { #[derive(Debug, Error)] #[error("unknown auto update variant `{0}`, must be `registry` or `local`")] pub struct ParseAutoUpdateError(String); - -/// Writes the line `key=joined_words` to the [`Formatter`]. `joined_words` is the given `words`, -/// where each word is escaped and joined together with the const parameter `C` as a separator. -fn writeln_escape_spaces(f: &mut Formatter, key: &str, words: I) -> fmt::Result -where - I: IntoIterator, - I::Item: AsRef, -{ - write!(f, "{key}=")?; - - let mut words = words.into_iter(); - - if let Some(first) = words.next() { - escape_spaces(f, first.as_ref())?; - } - - for word in words { - f.write_char(C)?; - escape_spaces(f, word.as_ref())?; - } - - writeln!(f) -} - -fn escape_spaces(f: &mut Formatter, word: &str) -> fmt::Result { - if word.contains(' ') { - write!(f, "\"{word}\"") - } else { - f.write_str(word) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn escape_spaces() { - struct Foo([&'static str; 3]); - - impl Display for Foo { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - writeln_escape_spaces::<' ', _>(f, "Foo", self.0) - } - } - - assert_eq!( - Foo(["one", "two", "three"]).to_string(), - "Foo=one two three\n" - ); - assert_eq!( - Foo(["one", "two three", "four"]).to_string(), - "Foo=one \"two three\" four\n" - ); - } -} diff --git a/src/quadlet/container.rs b/src/quadlet/container.rs index 7b2dc03..7e26fa0 100644 --- a/src/quadlet/container.rs +++ b/src/quadlet/container.rs @@ -2,303 +2,238 @@ use std::{ fmt::{self, Display, Formatter}, iter, net::{Ipv4Addr, Ipv6Addr}, + ops::Not, path::PathBuf, }; use clap::ValueEnum; +use serde::{Serialize, Serializer}; -use super::{writeln_escape_spaces, AutoUpdate}; +use crate::serde::quadlet::{quote_spaces_join_colon, quote_spaces_join_space}; + +use super::AutoUpdate; -#[derive(Debug, Default, Clone, PartialEq)] #[allow(clippy::struct_excessive_bools)] +#[derive(Serialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "PascalCase")] pub struct Container { + /// Add these capabilities, in addition to the default Podman capability set, to the container. + #[serde( + serialize_with = "quote_spaces_join_space", + skip_serializing_if = "Vec::is_empty" + )] pub add_capability: Vec, + + /// Adds a device node from the host into the container. pub add_device: Vec, + + /// Set one or more OCI annotations on the container. + #[serde( + serialize_with = "quote_spaces_join_space", + skip_serializing_if = "Vec::is_empty" + )] pub annotation: Vec, + + /// Indicates whether the container will be auto-updated. pub auto_update: Option, + + /// The (optional) name of the Podman container. pub container_name: Option, + + /// Drop these capabilities from the default podman capability set, or `all` to drop all capabilities. + #[serde( + serialize_with = "quote_spaces_join_space", + skip_serializing_if = "Vec::is_empty" + )] pub drop_capability: Vec, + + /// Set an environment variable in the container. + #[serde( + serialize_with = "quote_spaces_join_space", + skip_serializing_if = "Vec::is_empty" + )] pub environment: Vec, + + /// Use a line-delimited file to set environment variables in the container. pub environment_file: Vec, + + /// Use the host environment inside of the container. + #[serde(skip_serializing_if = "Not::not")] pub environment_host: bool, + + /// If this is set then it defines what command line to run in the container. pub exec: Option, + + /// Exposes a port, or a range of ports, from the host to the container. pub expose_host_port: Vec, + + /// The (numeric) GID to run as inside the container. pub group: Option, + + /// Set or alter a healthcheck command for a container. pub health_cmd: Option, + + /// Set an interval for the healthchecks. pub health_interval: Option, + + /// Action to take once the container transitions to an unhealthy state. pub health_on_failure: Option, + + /// The number of retries allowed before a healthcheck is considered to be unhealthy. pub health_retries: Option, + + /// The initialization time needed for a container to bootstrap. pub health_start_period: Option, + + /// Set a startup healthcheck command for a container. pub health_startup_cmd: Option, + + /// Set an interval for the startup healthcheck. pub health_startup_interval: Option, + + /// The number of attempts allowed before the startup healthcheck restarts the container. pub health_startup_retries: Option, + + /// The number of successful runs required before the startup healthcheck succeeds + /// and the regular healthcheck begins. pub health_startup_success: Option, + + /// The maximum time a startup healthcheck command has to complete before it is marked as failed. pub health_startup_timeout: Option, + + /// The maximum time allowed to complete the healthcheck before an interval is considered failed. pub health_timeout: Option, + + /// Sets the host name that is available inside the container. pub host_name: Option, + + /// The image to run in the container. pub image: String, + + /// Specify a static IPv4 address for the container. + #[serde(rename = "IP")] pub ip: Option, + + /// Specify a static IPv6 address for the container. + #[serde(rename = "IP6")] pub ip6: Option, + + /// Set one or more OCI labels on the container. + #[serde( + serialize_with = "quote_spaces_join_space", + skip_serializing_if = "Vec::is_empty" + )] pub label: Vec, + + /// Set the log-driver used by Podman when running the container. pub log_driver: Option, + + /// The paths to mask. A masked path cannot be accessed inside the container. + #[serde( + serialize_with = "quote_spaces_join_colon", + skip_serializing_if = "Vec::is_empty" + )] pub mask: Vec, + + /// Attach a filesystem mount to the container. pub mount: Vec, + + /// Specify a custom network for the container. pub network: Vec, + + /// If enabled, this disables the container processes from gaining additional + /// privileges via things like setuid and file capabilities. + #[serde(skip_serializing_if = "Not::not")] pub no_new_privileges: bool, + + /// The rootfs to use for the container. pub rootfs: Option, + + /// Enable container handling of `sd_notify`. + #[serde(skip_serializing_if = "Not::not")] pub notify: bool, + + /// A list of arguments passed directly to the end of the `podman run` command + /// in the generated file, right before the image name in the command line. pub podman_args: Option, + + /// Exposes a port, or a range of ports, from the container to the host. pub publish_port: Vec, + + /// Set the image pull policy. pub pull: Option, + + /// If enabled, makes the image read-only. + #[serde(skip_serializing_if = "Not::not")] pub read_only: bool, + + /// If enabled, the container has a minimal init process inside the container + /// that forwards signals and reaps processes. + #[serde(skip_serializing_if = "Not::not")] pub run_init: bool, + + /// Set the seccomp profile to use in the container. pub seccomp_profile: Option, + + /// Turn off label separation for the container. + #[serde(skip_serializing_if = "Not::not")] pub security_label_disable: bool, + + /// Set the label file type for the container files. pub security_label_file_type: Option, + + /// Set the label process level for the container processes. pub security_label_level: Option, + + /// Allow SecurityLabels to function within the container. + #[serde(skip_serializing_if = "Not::not")] pub security_label_nested: bool, + + /// Set the label process type for the container processes. pub security_label_type: Option, + + /// Use a Podman secret in the container either as a file or an environment variable. pub secret: Vec, + + /// Configures namespaced kernel parameters for the container. + #[serde( + serialize_with = "quote_spaces_join_space", + skip_serializing_if = "Vec::is_empty" + )] pub sysctl: Vec, + + /// Mount a tmpfs in the container. pub tmpfs: Vec, + + /// The timezone to run the container in. pub timezone: Option, + + /// The paths to unmask. pub unmask: Option, + + /// The (numeric) UID to run as inside the container. pub user: Option, + + /// Set the user namespace mode for the container. + #[serde(rename = "UserNS")] pub user_ns: Option, + + /// If enabled, the container has a fresh tmpfs mounted on `/tmp`. + #[serde(skip_serializing_if = "Not::not")] pub volatile_tmp: bool, + + /// Mount a volume in the container. pub volume: Vec, + + /// Working directory inside the container. pub working_dir: Option, } impl Display for Container { #[allow(clippy::too_many_lines)] fn fmt(&self, f: &mut Formatter) -> fmt::Result { - writeln!(f, "[Container]")?; - - writeln!(f, "Image={}", self.image)?; - - if !self.add_capability.is_empty() { - writeln!(f, "AddCapability={}", self.add_capability.join(" "))?; - } - - for device in &self.add_device { - writeln!(f, "AddDevice={device}")?; - } - - if !self.annotation.is_empty() { - writeln_escape_spaces::<' ', _>(f, "Annotation", &self.annotation)?; - } - - if let Some(auto_update) = self.auto_update { - writeln!(f, "AutoUpdate={auto_update}")?; - } - - if let Some(name) = &self.container_name { - writeln!(f, "ContainerName={name}")?; - } - - if !self.drop_capability.is_empty() { - writeln!(f, "DropCapability={}", self.drop_capability.join(" "))?; - } - - if !self.environment.is_empty() { - writeln_escape_spaces::<' ', _>(f, "Environment", &self.environment)?; - } - - for file in &self.environment_file { - writeln!(f, "EnvironmentFile={}", file.display())?; - } - - if self.environment_host { - writeln!(f, "EnvironmentHost=true")?; - } - - for port in &self.expose_host_port { - writeln!(f, "ExposeHostPort={port}")?; - } - - if let Some(group) = &self.group { - writeln!(f, "Group={group}")?; - } - - if let Some(command) = &self.health_cmd { - writeln!(f, "HealthCmd={command}")?; - } - - if let Some(interval) = &self.health_interval { - writeln!(f, "HealthInterval={interval}")?; - } - - if let Some(action) = &self.health_on_failure { - writeln!(f, "HealthOnFailure={action}")?; - } - - if let Some(retries) = &self.health_retries { - writeln!(f, "HealthRetries={retries}")?; - } - - if let Some(period) = &self.health_start_period { - writeln!(f, "HealthStartPeriod={period}")?; - } - - if let Some(command) = &self.health_startup_cmd { - writeln!(f, "HealthStartupCmd={command}")?; - } - - if let Some(interval) = &self.health_startup_interval { - writeln!(f, "HealthStartupInterval={interval}")?; - } - - if let Some(retries) = &self.health_startup_retries { - writeln!(f, "HealthStartupRetries={retries}")?; - } - - if let Some(retries) = &self.health_startup_success { - writeln!(f, "HealthStartupSuccess={retries}")?; - } - - if let Some(timeout) = &self.health_startup_timeout { - writeln!(f, "HealthStartupTimeout={timeout}")?; - } - - if let Some(timeout) = &self.health_timeout { - writeln!(f, "HealthTimeout={timeout}")?; - } - - if let Some(host_name) = &self.host_name { - writeln!(f, "HostName={host_name}")?; - } - - if let Some(ip) = &self.ip { - writeln!(f, "IP={ip}")?; - } - - if let Some(ip6) = &self.ip6 { - writeln!(f, "IP6={ip6}")?; - } - - if !self.label.is_empty() { - writeln_escape_spaces::<' ', _>(f, "Label", &self.label)?; - } - - if let Some(log_driver) = &self.log_driver { - writeln!(f, "LogDriver={log_driver}")?; - } - - // each mask item is a separate path, to be escaped and joined - if !self.mask.is_empty() { - writeln_escape_spaces::<':', _>(f, "Mask", &self.mask)?; - } - - for mount in &self.mount { - writeln!(f, "Mount={mount}")?; - } - - for network in &self.network { - writeln!(f, "Network={network}")?; - } - - if self.no_new_privileges { - writeln!(f, "NoNewPrivileges=true")?; - } - - if let Some(rootfs) = &self.rootfs { - writeln!(f, "Rootfs={rootfs}")?; - } - - if self.notify { - writeln!(f, "Notify=true")?; - } - - for port in &self.publish_port { - writeln!(f, "PublishPort={port}")?; - } - - if let Some(pull) = self.pull { - writeln!(f, "Pull={pull}")?; - } - - if self.read_only { - writeln!(f, "ReadOnly=true")?; - } - - if self.run_init { - writeln!(f, "RunInit=true")?; - } - - if let Some(profile) = &self.seccomp_profile { - writeln!(f, "SeccompProfile={profile}")?; - } - - if self.security_label_disable { - writeln!(f, "SecurityLabelDisable=true")?; - } - - if let Some(file_type) = &self.security_label_file_type { - writeln!(f, "SecurityLabelFileType={file_type}")?; - } - - if let Some(level) = &self.security_label_level { - writeln!(f, "SecurityLabelLevel={level}")?; - } - - if self.security_label_nested { - writeln!(f, "SecurityLabelNested=true")?; - } - - if let Some(label_type) = &self.security_label_type { - writeln!(f, "SecurityLabelType={label_type}")?; - } - - for secret in &self.secret { - writeln!(f, "Secret={secret}")?; - } - - if !self.sysctl.is_empty() { - writeln_escape_spaces::<' ', _>(f, "Sysctl", &self.sysctl)?; - } - - for tmpfs in &self.tmpfs { - writeln!(f, "Tmpfs={tmpfs}")?; - } - - if let Some(timezone) = &self.timezone { - writeln!(f, "Timezone={timezone}")?; - } - - if let Some(unmask) = &self.unmask { - writeln_escape_spaces::<':', _>(f, "Unmask", unmask)?; - } - - if let Some(user) = &self.user { - writeln!(f, "User={user}")?; - } - - if let Some(user_ns) = &self.user_ns { - writeln!(f, "UserNS={user_ns}")?; - } - - if self.volatile_tmp { - writeln!(f, "VolatileTmp=true")?; - } - - for volume in &self.volume { - writeln!(f, "Volume={volume}")?; - } - - if let Some(working_dir) = &self.working_dir { - writeln!(f, "WorkingDir={}", working_dir.display())?; - } - - if let Some(podman_args) = &self.podman_args { - writeln!(f, "PodmanArgs={podman_args}")?; - } - - if let Some(exec) = &self.exec { - writeln!(f, "Exec={exec}")?; - } - - Ok(()) + let container = crate::serde::quadlet::to_string(self).map_err(|_| fmt::Error)?; + f.write_str(&container) } } @@ -334,6 +269,12 @@ impl Display for PullPolicy { } } +impl Serialize for PullPolicy { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_ref()) + } +} + /// Options for the `Unmask=` quadlet option. #[derive(Debug, Clone, PartialEq)] pub enum Unmask { @@ -371,6 +312,12 @@ impl Default for Unmask { } } +impl Serialize for Unmask { + fn serialize(&self, serializer: S) -> Result { + quote_spaces_join_colon(self, serializer) + } +} + impl> Extend for Unmask { fn extend>(&mut self, iter: T) { for path in iter { @@ -413,6 +360,15 @@ impl<'a> Iterator for UnmaskIter<'a> { mod tests { use super::*; + #[test] + fn container_default_empty() { + let container = Container { + image: String::from("image"), + ..Container::default() + }; + assert_eq!(container.to_string(), "[Container]\nImage=image\n"); + } + mod unmask { use super::*; diff --git a/src/quadlet/install.rs b/src/quadlet/install.rs index 83b9913..adfbd20 100644 --- a/src/quadlet/install.rs +++ b/src/quadlet/install.rs @@ -1,25 +1,30 @@ use std::fmt::{self, Display, Formatter}; -use super::writeln_escape_spaces; +use serde::Serialize; -#[derive(Debug, Clone, PartialEq)] +use crate::serde::quadlet::quote_spaces_join_space; + +#[derive(Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "PascalCase")] pub struct Install { + /// Add weak parent dependencies to the unit. + #[serde( + serialize_with = "quote_spaces_join_space", + skip_serializing_if = "Vec::is_empty" + )] pub wanted_by: Vec, + + /// Add stronger parent dependencies to the unit. + #[serde( + serialize_with = "quote_spaces_join_space", + skip_serializing_if = "Vec::is_empty" + )] pub required_by: Vec, } impl Display for Install { fn fmt(&self, f: &mut Formatter) -> fmt::Result { - writeln!(f, "[Install]")?; - - if !self.wanted_by.is_empty() { - writeln_escape_spaces::<' ', _>(f, "WantedBy", &self.wanted_by)?; - } - - if !self.required_by.is_empty() { - writeln_escape_spaces::<' ', _>(f, "RequiredBy", &self.required_by)?; - } - - Ok(()) + let install = crate::serde::quadlet::to_string(self).map_err(|_| fmt::Error)?; + f.write_str(&install) } } diff --git a/src/quadlet/kube.rs b/src/quadlet/kube.rs index 92a2abc..fa6b406 100644 --- a/src/quadlet/kube.rs +++ b/src/quadlet/kube.rs @@ -3,14 +3,33 @@ use std::{ path::PathBuf, }; -#[derive(Debug, Clone, PartialEq)] +use serde::Serialize; + +#[derive(Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "PascalCase")] pub struct Kube { + /// Pass the Kubernetes ConfigMap YAML at path to `podman kube play`. pub config_map: Vec, + + /// Set the log-driver Podman uses when running the container. pub log_driver: Option, + + /// Specify a custom network for the container. pub network: Vec, + + /// This key contains a list of arguments passed directly to the end of the `podman kube play` + /// command in the generated file, right before the path to the yaml file in the command line. pub podman_args: Option, + + /// Exposes a port, or a range of ports, from the container to the host. pub publish_port: Vec, + + /// Set the user namespace mode for the container. + #[serde(rename = "UserNS")] pub user_ns: Option, + + /// The path, absolute or relative to the location of the unit file, + /// to the Kubernetes YAML file to use. pub yaml: String, } @@ -30,34 +49,18 @@ impl Kube { impl Display for Kube { fn fmt(&self, f: &mut Formatter) -> fmt::Result { - writeln!(f, "[Kube]")?; - - writeln!(f, "Yaml={}", self.yaml)?; - - for config_map in &self.config_map { - writeln!(f, "ConfigMap={}", config_map.display())?; - } - - if let Some(log_driver) = &self.log_driver { - writeln!(f, "LogDriver={log_driver}")?; - } - - for network in &self.network { - writeln!(f, "Network={network}")?; - } - - if let Some(podman_args) = &self.podman_args { - writeln!(f, "PodmanArgs={podman_args}")?; - } - - for port in &self.publish_port { - writeln!(f, "PublishPort={port}")?; - } + let kube = crate::serde::quadlet::to_string(self).map_err(|_| fmt::Error)?; + f.write_str(&kube) + } +} - if let Some(user_ns) = &self.user_ns { - writeln!(f, "UserNS={user_ns}")?; - } +#[cfg(test)] +mod tests { + use super::*; - Ok(()) + #[test] + fn kube_default_empty() { + let kube = Kube::new(String::from("yaml")); + assert_eq!(kube.to_string(), "[Kube]\nYaml=yaml\n"); } } diff --git a/src/quadlet/network.rs b/src/quadlet/network.rs index 22dfd85..77fb2e5 100644 --- a/src/quadlet/network.rs +++ b/src/quadlet/network.rs @@ -1,25 +1,59 @@ use std::{ fmt::{self, Display, Formatter}, net::IpAddr, + ops::Not, }; use color_eyre::eyre::{self, Context}; use ipnet::IpNet; +use serde::Serialize; -use super::writeln_escape_spaces; +use crate::serde::quadlet::quote_spaces_join_space; -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Serialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "PascalCase")] pub struct Network { + /// If enabled, disables the DNS plugin for this network. + #[serde(skip_serializing_if = "Not::not")] pub disable_dns: bool, + + /// Driver to manage the network. pub driver: Option, + + /// Define a gateway for the subnet. pub gateway: Vec, + + /// Restrict external access of this network. + #[serde(skip_serializing_if = "Not::not")] pub internal: bool, + + /// Set the ipam driver (IP Address Management Driver) for the network. + #[serde(rename = "IPAMDriver")] pub ipam_driver: Option, + + /// Allocate container IP from a range. + #[serde(rename = "IPRange")] pub ip_range: Vec, + + /// Enable IPv6 (Dual Stack) networking. + #[serde(rename = "IPv6", skip_serializing_if = "Not::not")] pub ipv6: bool, + + /// Set one or more OCI labels on the network. + #[serde( + serialize_with = "quote_spaces_join_space", + skip_serializing_if = "Vec::is_empty" + )] pub label: Vec, + + /// Set driver specific options. pub options: Option, + + /// This key contains a list of arguments passed directly to the end of the `podman network create` + /// command in the generated file, right before the name of the network in the command line. pub podman_args: Option, + + /// The subnet in CIDR notation. pub subnet: Vec, } @@ -90,52 +124,18 @@ impl TryFrom for Network { impl Display for Network { fn fmt(&self, f: &mut Formatter) -> fmt::Result { - writeln!(f, "[Network]")?; - - if self.disable_dns { - writeln!(f, "DisableDNS=true")?; - } - - if let Some(driver) = &self.driver { - writeln!(f, "Driver={driver}")?; - } - - for gateway in &self.gateway { - writeln!(f, "Gateway={gateway}")?; - } - - if self.internal { - writeln!(f, "Internal=true")?; - } - - if let Some(driver) = &self.ipam_driver { - writeln!(f, "IPAMDriver={driver}")?; - } - - for ip_range in &self.ip_range { - writeln!(f, "IPRange={ip_range}")?; - } - - if self.ipv6 { - writeln!(f, "IPv6=true")?; - } - - if !self.label.is_empty() { - writeln_escape_spaces::<' ', _>(f, "Label", &self.label)?; - } - - if let Some(options) = &self.options { - writeln!(f, "Options={options}")?; - } - - if let Some(podman_args) = &self.podman_args { - writeln!(f, "PodmanArgs={podman_args}")?; - } + let network = crate::serde::quadlet::to_string(self).map_err(|_| fmt::Error)?; + f.write_str(&network) + } +} - for subnet in &self.subnet { - writeln!(f, "Subnet={subnet}")?; - } +#[cfg(test)] +mod tests { + use super::*; - Ok(()) + #[test] + fn network_default_empty() { + let network = Network::default(); + assert_eq!(network.to_string(), "[Network]\n"); } } diff --git a/src/quadlet/volume.rs b/src/quadlet/volume.rs index 76e1b2c..81a8aed 100644 --- a/src/quadlet/volume.rs +++ b/src/quadlet/volume.rs @@ -1,20 +1,46 @@ -use std::fmt::{self, Display, Formatter}; +use std::{ + fmt::{self, Display, Formatter}, + ops::Not, +}; use color_eyre::eyre::{self, Context}; +use serde::Serialize; -use crate::cli::volume::opt::Opt; +use crate::{cli::volume::opt::Opt, serde::quadlet::quote_spaces_join_space}; -use super::writeln_escape_spaces; - -#[derive(Debug, Default, Clone, PartialEq)] +#[derive(Serialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "PascalCase")] pub struct Volume { + /// If enabled, the content of the image located at the mount point of the volume + /// is copied into the volume on the first run. + #[serde(skip_serializing_if = "Not::not")] pub copy: bool, + + /// The path of a device which is mounted for the volume. pub device: Option, + + /// The host (numeric) GID, or group name to use as the group for the volume. pub group: Option, + + /// Set one or more OCI labels on the volume. + #[serde( + serialize_with = "quote_spaces_join_space", + skip_serializing_if = "Vec::is_empty" + )] pub label: Vec, + + /// The mount options to use for a filesystem as used by the `mount` command -o option. pub options: Option, + + /// This key contains a list of arguments passed directly to the end of the `podman volume create` + /// command in the generated file, right before the name of the network in the command line. pub podman_args: Option, + + /// The filesystem type of `Device` as used by the `mount` commands `-t` option. + #[serde(rename = "Type")] pub fs_type: Option, + + /// The host (numeric) UID, or user name to use as the owner for the volume. pub user: Option, } @@ -64,40 +90,18 @@ impl TryFrom for Volume { impl Display for Volume { fn fmt(&self, f: &mut Formatter) -> fmt::Result { - writeln!(f, "[Volume]")?; - - if self.copy { - writeln!(f, "Copy=true")?; - } - - if let Some(device) = &self.device { - writeln!(f, "Device={device}")?; - } - - if let Some(group) = &self.group { - writeln!(f, "Group={group}")?; - } - - if !self.label.is_empty() { - writeln_escape_spaces::<' ', _>(f, "Label", &self.label)?; - } - - if let Some(options) = &self.options { - writeln!(f, "Options={options}")?; - } - - if let Some(podman_args) = &self.podman_args { - writeln!(f, "PodmanArgs={podman_args}")?; - } - - if let Some(fs_type) = &self.fs_type { - writeln!(f, "Type={fs_type}")?; - } + let volume = crate::serde::quadlet::to_string(self).map_err(|_| fmt::Error)?; + f.write_str(&volume) + } +} - if let Some(user) = &self.user { - writeln!(f, "User={user}")?; - } +#[cfg(test)] +mod tests { + use super::*; - Ok(()) + #[test] + fn volume_default_empty() { + let volume = Volume::default(); + assert_eq!(volume.to_string(), "[Volume]\n"); } } diff --git a/src/serde/quadlet.rs b/src/serde/quadlet.rs new file mode 100644 index 0000000..7be76b7 --- /dev/null +++ b/src/serde/quadlet.rs @@ -0,0 +1,650 @@ +use std::fmt::{Display, Write}; + +use serde::{ + ser::{self, Impossible}, + Serialize, +}; +use thiserror::Error; + +/// Alias for `quote_spaces_join::<' ', T, S>()`. +pub fn quote_spaces_join_space<'a, T, S>(iter: &'a T, serializer: S) -> Result +where + &'a T: IntoIterator, + <&'a T as IntoIterator>::Item: AsRef, + S: ser::Serializer, +{ + quote_spaces_join::<' ', _, _>(iter, serializer) +} + +/// Alias for `quote_spaces_join::<':', T, S>()`. +pub fn quote_spaces_join_colon<'a, T, S>(iter: &'a T, serializer: S) -> Result +where + &'a T: IntoIterator, + <&'a T as IntoIterator>::Item: AsRef, + S: ser::Serializer, +{ + quote_spaces_join::<':', _, _>(iter, serializer) +} + +/// Serializes `iter` by joining its items separated by `C`. +/// Each item is quoted if it has spaces before being joined. +/// +/// For example, `["one", "two three", "four"]`, if C = ' ' +/// is serialized as `one "two three" four`. +pub fn quote_spaces_join<'a, const C: char, T, S>( + iter: &'a T, + serializer: S, +) -> Result +where + &'a T: IntoIterator, + <&'a T as IntoIterator>::Item: AsRef, + S: ser::Serializer, +{ + let mut output = String::new(); + let mut iter = iter.into_iter(); + + if let Some(first) = iter.next() { + quote_spaces_push(&mut output, first.as_ref()); + } + + for item in iter { + output.push(C); + quote_spaces_push(&mut output, item.as_ref()); + } + + output.serialize(serializer) +} + +/// Appends `item` to `output`, quoting it if it contains spaces. +fn quote_spaces_push(output: &mut String, item: &str) { + if item.contains(' ') { + output.push('"'); + output.push_str(item); + output.push('"'); + } else { + output.push_str(item); + } +} + +/// Serializes `value` to a string using a serializer designed +/// for structs that represent a section of a quadlet file. +/// +/// # Errors +/// +/// Returns an error if the value errors while serializing, or if given +/// an invalid type, such as a non-struct or a struct with a nested map. +/// +/// ``` +/// #[derive(Serialize)] +/// #[serde(rename_all = "PascalCase")] +/// struct Example { +/// str: &'static str, +/// vec: Vec, +/// } +/// let example = Example { +/// str: "Hello world!", +/// vec: vec![1, 2], +/// }; +/// assert_eq!( +/// to_string(example).unwrap(), +/// "[Example]\n\ +/// Str=Hello world!\n\ +/// Vec=1\n\ +/// Vec=2\n" +/// ); +/// ``` +pub fn to_string(value: T) -> Result { + let mut serializer = Serializer::default(); + value.serialize(&mut serializer)?; + Ok(serializer.output) +} + +#[derive(Error, Debug, PartialEq, Eq)] +pub enum Error { + #[error("error while serializing: {0}")] + Custom(String), + #[error("type cannot be serialized")] + InvalidType, +} + +impl ser::Error for Error { + fn custom(msg: T) -> Self + where + T: Display, + { + Self::Custom(msg.to_string()) + } +} + +/// A serializer for converting structs to quadlet file sections. +#[derive(Default)] +struct Serializer { + output: String, +} + +macro_rules! invalid_primitives { + ($($f:ident: $t:ty,)*) => { + $( + fn $f(self, _v: $t) -> Result { + Err(Error::InvalidType) + } + )* + }; +} + +impl ser::Serializer for &mut Serializer { + type Ok = (); + + type Error = Error; + + type SerializeSeq = Impossible<(), Error>; + + type SerializeTuple = Impossible<(), Error>; + + type SerializeTupleStruct = Impossible<(), Error>; + + type SerializeTupleVariant = Impossible<(), Error>; + + type SerializeMap = Impossible<(), Error>; + + type SerializeStruct = Self; + + type SerializeStructVariant = Self; + + invalid_primitives! { + serialize_bool: bool, + serialize_i8: i8, + serialize_i16: i16, + serialize_i32: i32, + serialize_i64: i64, + serialize_i128: i128, + serialize_u8: u8, + serialize_u16: u16, + serialize_u32: u32, + serialize_u64: u64, + serialize_u128: u128, + serialize_f32: f32, + serialize_f64: f64, + serialize_char: char, + serialize_str: &str, + serialize_bytes: &[u8], + } + + fn serialize_none(self) -> Result { + Err(Error::InvalidType) + } + + fn serialize_some(self, _value: &T) -> Result + where + T: Serialize, + { + Err(Error::InvalidType) + } + + fn serialize_unit(self) -> Result { + Err(Error::InvalidType) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result { + Err(Error::InvalidType) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + ) -> Result { + Err(Error::InvalidType) + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + _value: &T, + ) -> Result + where + T: Serialize, + { + Err(Error::InvalidType) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _value: &T, + ) -> Result + where + T: Serialize, + { + Err(Error::InvalidType) + } + + fn serialize_seq(self, _len: Option) -> Result { + Err(Error::InvalidType) + } + + fn serialize_tuple(self, _len: usize) -> Result { + Err(Error::InvalidType) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + Err(Error::InvalidType) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(Error::InvalidType) + } + + fn serialize_map(self, _len: Option) -> Result { + Err(Error::InvalidType) + } + + fn serialize_struct( + self, + name: &'static str, + _len: usize, + ) -> Result { + writeln!(self.output, "[{name}]").expect("write to String never fails"); + Ok(self) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + _len: usize, + ) -> Result { + writeln!(self.output, "[{variant}]").expect("write to String never fails"); + Ok(self) + } +} + +impl ser::SerializeStruct for &mut Serializer { + type Ok = (); + + type Error = Error; + + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> + where + T: Serialize, + { + value.serialize(&mut ValueSerializer { + serializer: self, + key, + }) + } + + fn end(self) -> Result { + Ok(()) + } +} + +impl ser::SerializeStructVariant for &mut Serializer { + type Ok = (); + + type Error = Error; + + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> + where + T: Serialize, + { + ser::SerializeStruct::serialize_field(self, key, value) + } + + fn end(self) -> Result { + ser::SerializeStruct::end(self) + } +} + +/// Serializes values for [`Serializer`]. +/// +/// Sequences are serialized on a separate lines, repeating the `key`. +struct ValueSerializer<'a> { + serializer: &'a mut Serializer, + key: &'static str, +} + +impl<'a> ValueSerializer<'a> { + /// Writes the `value` to `serializer.output` as `key=value`. + fn write_value(&mut self, value: impl Display) { + writeln!(self.serializer.output, "{}={value}", self.key) + .expect("write to String never fails"); + } +} + +macro_rules! serialize_primitives { + ($($f:ident: $t:ty,)*) => { + $( + fn $f(self, v: $t) -> Result { + self.write_value(v); + Ok(()) + } + )* + }; +} + +impl<'a> ser::Serializer for &mut ValueSerializer<'a> { + type Ok = (); + + type Error = Error; + + type SerializeSeq = Self; + + type SerializeTuple = Self; + + type SerializeTupleStruct = Self; + + type SerializeTupleVariant = Self; + + type SerializeMap = Impossible<(), Error>; + + type SerializeStruct = Impossible<(), Error>; + + type SerializeStructVariant = Impossible<(), Error>; + + serialize_primitives! { + serialize_bool: bool, + serialize_i8: i8, + serialize_i16: i16, + serialize_i32: i32, + serialize_i64: i64, + serialize_i128: i128, + serialize_u8: u8, + serialize_u16: u16, + serialize_u32: u32, + serialize_u64: u64, + serialize_u128: u128, + serialize_f32: f32, + serialize_f64: f64, + serialize_char: char, + serialize_str: &str, + } + + fn serialize_bytes(self, _v: &[u8]) -> Result { + Err(Error::InvalidType) + } + + fn serialize_none(self) -> Result { + Ok(()) + } + + fn serialize_some(self, value: &T) -> Result + where + T: Serialize, + { + value.serialize(self) + } + + fn serialize_unit(self) -> Result { + Err(Error::InvalidType) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result { + Err(Error::InvalidType) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + ) -> Result { + self.serialize_str(variant) + } + + fn serialize_newtype_struct( + self, + _name: &'static str, + value: &T, + ) -> Result + where + T: Serialize, + { + value.serialize(self) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + value: &T, + ) -> Result + where + T: Serialize, + { + value.serialize(self) + } + + fn serialize_seq(self, _len: Option) -> Result { + Ok(self) + } + + fn serialize_tuple(self, _len: usize) -> Result { + Ok(self) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + Ok(self) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Ok(self) + } + + fn serialize_map(self, _len: Option) -> Result { + Err(Error::InvalidType) + } + + fn serialize_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + Err(Error::InvalidType) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(Error::InvalidType) + } +} + +impl<'a> ser::SerializeSeq for &mut ValueSerializer<'a> { + type Ok = (); + + type Error = Error; + + fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + value.serialize(&mut **self) + } + + fn end(self) -> Result { + Ok(()) + } +} + +impl<'a> ser::SerializeTuple for &mut ValueSerializer<'a> { + type Ok = (); + + type Error = Error; + + fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result { + ser::SerializeSeq::end(self) + } +} + +impl<'a> ser::SerializeTupleStruct for &mut ValueSerializer<'a> { + type Ok = (); + + type Error = Error; + + fn serialize_field(&mut self, value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result { + ser::SerializeSeq::end(self) + } +} + +impl<'a> ser::SerializeTupleVariant for &mut ValueSerializer<'a> { + type Ok = (); + + type Error = Error; + + fn serialize_field(&mut self, value: &T) -> Result<(), Self::Error> + where + T: Serialize, + { + ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result { + ser::SerializeSeq::end(self) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use std::collections::HashMap; + + use super::*; + + #[test] + fn basic_struct() { + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + struct Test { + one: u8, + two: &'static str, + } + + let sut = Test { one: 1, two: "two" }; + assert_eq!( + to_string(sut).unwrap(), + "[Test]\n\ + One=1\n\ + Two=two\n" + ); + } + + #[test] + fn sequence() { + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + struct Test { + vec: Vec, + } + + let sut = Test { vec: vec![1, 2, 3] }; + assert_eq!( + to_string(sut).unwrap(), + "[Test]\n\ + Vec=1\n\ + Vec=2\n\ + Vec=3\n" + ); + } + + #[test] + fn sequence_join() { + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + struct Test { + #[serde(serialize_with = "quote_spaces_join_space")] + vec: Vec<&'static str>, + } + + let sut = Test { + vec: vec!["one", "two three", "four"], + }; + assert_eq!( + to_string(sut).unwrap(), + "[Test]\n\ + Vec=one \"two three\" four\n" + ); + } + + #[test] + fn empty_is_empty() { + #[derive(Serialize, Default)] + #[serde(rename_all = "PascalCase")] + struct Test { + option: Option<&'static str>, + vec: Vec<&'static str>, + #[serde( + serialize_with = "quote_spaces_join_space", + skip_serializing_if = "Vec::is_empty" + )] + vec_joined: Vec<&'static str>, + } + + assert_eq!(to_string(Test::default()).unwrap(), "[Test]\n"); + } + + #[test] + fn nested_map_err() { + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + struct Test { + map: HashMap<&'static str, &'static str>, + } + + let sut = Test { + map: [("one", "one")].into(), + }; + assert_eq!(to_string(sut).unwrap_err(), Error::InvalidType); + } +} From 92ef06b16c9ffc446819397e58eedf154194cb0c Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Wed, 13 Dec 2023 01:57:15 -0600 Subject: [PATCH 15/23] fix: escape newlines in joined qualet values Fixes: #32 Also, in joined quadlet values, strings containing any whitespace are now quoted, not just if it conains a space. --- src/serde/quadlet.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/serde/quadlet.rs b/src/serde/quadlet.rs index 7be76b7..f61ef3b 100644 --- a/src/serde/quadlet.rs +++ b/src/serde/quadlet.rs @@ -57,9 +57,14 @@ where /// Appends `item` to `output`, quoting it if it contains spaces. fn quote_spaces_push(output: &mut String, item: &str) { - if item.contains(' ') { + if item.contains(char::is_whitespace) { output.push('"'); - output.push_str(item); + for char in item.chars() { + match char { + '\n' => output.push_str(r"\n"), + _ => output.push(char), + } + } output.push('"'); } else { output.push_str(item); @@ -647,4 +652,15 @@ mod tests { }; assert_eq!(to_string(sut).unwrap_err(), Error::InvalidType); } + + #[test] + fn quote_spaces() { + let mut output = String::new(); + quote_spaces_push(&mut output, "test1 test2"); + assert_eq!(output, r#""test1 test2""#); + + output.clear(); + quote_spaces_push(&mut output, "test1\ntest2"); + assert_eq!(output, r#""test1\ntest2""#); + } } From be4684385b17a6098594dedc7df25c5f6c3bd5ba Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Wed, 13 Dec 2023 02:47:59 -0600 Subject: [PATCH 16/23] chore: update dependencies --- Cargo.lock | 139 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 70 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 84c527d..c128dcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab91ebe16eb252986481c5b62f6098f3b698a45e34b5b98200cf20dd2484a44" +checksum = "d664a92ecae85fd0a7392615844904654d1d5f5514837f471ddef4a057aba1b6" dependencies = [ "anstyle", "anstyle-parse", @@ -48,30 +48,30 @@ checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" [[package]] name = "anstyle-parse" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317b9a89c1868f5ea6ff1d9539a69f45dffc21ce321ac1fd1160dfa48c8e2140" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.1" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0699d10d2f4d628a98ee7b57b289abbc98ff3bad977cb3152709d4bf2330628" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" dependencies = [ "anstyle", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -109,11 +109,11 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17ae5ebefcc48e7452b4987947920dac9450be1110cadf34d1b8c116bdbaf97c" dependencies = [ - "async-lock 3.1.2", + "async-lock 3.2.0", "async-task", "concurrent-queue", "fastrand 2.0.1", - "futures-lite 2.0.1", + "futures-lite 2.1.0", "slab", ] @@ -151,18 +151,18 @@ dependencies = [ [[package]] name = "async-io" -version = "2.2.1" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6d3b15875ba253d1110c740755e246537483f152fa334f91abd7fe84c88b3ff" +checksum = "6afaa937395a620e33dc6a742c593c01aced20aa376ffb0f628121198578ccc7" dependencies = [ - "async-lock 3.1.2", + "async-lock 3.2.0", "cfg-if", "concurrent-queue", "futures-io", - "futures-lite 2.0.1", + "futures-lite 2.1.0", "parking", "polling 3.3.1", - "rustix 0.38.25", + "rustix 0.38.28", "slab", "tracing", "windows-sys 0.52.0", @@ -179,9 +179,9 @@ dependencies = [ [[package]] name = "async-lock" -version = "3.1.2" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea8b3453dd7cc96711834b75400d671b73e3656975fa68d9f277163b7f7e316" +checksum = "7125e42787d53db9dd54261812ef17e937c95a51e4d291373b670342fa44310c" dependencies = [ "event-listener 4.0.0", "event-listener-strategy", @@ -201,7 +201,7 @@ dependencies = [ "cfg-if", "event-listener 3.1.0", "futures-lite 1.13.0", - "rustix 0.38.25", + "rustix 0.38.28", "windows-sys 0.48.0", ] @@ -213,7 +213,7 @@ checksum = "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.41", ] [[package]] @@ -222,13 +222,13 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e47d90f65a225c4527103a8d747001fc56e375203592b25ad103e1ca13124c5" dependencies = [ - "async-io 2.2.1", + "async-io 2.2.2", "async-lock 2.8.0", "atomic-waker", "cfg-if", "futures-core", "futures-io", - "rustix 0.38.25", + "rustix 0.38.28", "signal-hook-registry", "slab", "windows-sys 0.48.0", @@ -248,7 +248,7 @@ checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.41", ] [[package]] @@ -312,11 +312,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a37913e8dc4ddcc604f0c6d3bf2887c995153af3611de9e23c352b44c1b9118" dependencies = [ "async-channel", - "async-lock 3.1.2", + "async-lock 3.2.0", "async-task", "fastrand 2.0.1", "futures-io", - "futures-lite 2.0.1", + "futures-lite 2.1.0", "piper", "tracing", ] @@ -360,9 +360,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.4.8" +version = "4.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2275f18819641850fa26c89acc84d465c1bf91ce57bc2748b28c420473352f64" +checksum = "bfaff671f6b22ca62406885ece523383b9b64022e341e53e009a62ebc47a45f2" dependencies = [ "clap_builder", "clap_derive", @@ -370,9 +370,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.4.8" +version = "4.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07cdf1b148b25c1e1f7a42225e30a0d99a615cd4637eae7365548dd4529b95bc" +checksum = "a216b506622bb1d316cd51328dce24e07bdff4a6128a47c7e7fad11878d5adbb" dependencies = [ "anstream", "anstyle", @@ -389,7 +389,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.41", ] [[package]] @@ -433,9 +433,9 @@ checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" [[package]] name = "concurrent-queue" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f057a694a54f12365049b0958a1685bb52d567f5593b355fbf685838e873d400" +checksum = "d16048cd947b08fa32c24458a22f5dc5e835264f689f4f5653210c69fd107363" dependencies = [ "crossbeam-utils", ] @@ -557,9 +557,9 @@ dependencies = [ [[package]] name = "docker-compose-types" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e06f731bc05616203869650c1a4f2c12d97edc3eef58f8c6a51fe430d31ad8ba" +checksum = "608ffe949fbffae4034bdde4bfa224238736ecc9e1a362997245c7282f49fa60" dependencies = [ "derive_builder", "indexmap", @@ -569,9 +569,9 @@ dependencies = [ [[package]] name = "duration-str" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e172e85f305d6a442b250bf40667ffcb91a24f52c9a1ca59e2fa991ac9b7790" +checksum = "a8bb6a301a95ba86fa0ebaf71d49ae4838c51f8b84cb88ed140dfb66452bb3c4" dependencies = [ "nom", "rust_decimal", @@ -596,7 +596,7 @@ checksum = "f95e2801cd355d4a1a3e3953ce6ee5ae9603a5c833455343a8bfe3f44d418246" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.41", ] [[package]] @@ -607,12 +607,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f258a7194e7f7c2a7837a8913aeab7fd8c383457034fa20ce4dd3dcb813e8eb8" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -722,14 +722,13 @@ dependencies = [ [[package]] name = "futures-lite" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3831c2651acb5177cbd83943f3d9c8912c5ad03c76afcc0e9511ba568ec5ebb" +checksum = "aeee267a1883f7ebef3700f262d2d54de95dfaf38189015a74fdc4e0c7ad8143" dependencies = [ "fastrand 2.0.1", "futures-core", "futures-io", - "memchr", "parking", "pin-project-lite", ] @@ -877,9 +876,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" [[package]] name = "k8s-openapi" @@ -903,9 +902,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.150" +version = "0.2.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" +checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" [[package]] name = "linux-raw-sys" @@ -915,9 +914,9 @@ checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" [[package]] name = "linux-raw-sys" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829" +checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" [[package]] name = "log" @@ -997,9 +996,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "ordered-float" @@ -1106,7 +1105,7 @@ dependencies = [ "cfg-if", "concurrent-queue", "pin-project-lite", - "rustix 0.38.25", + "rustix 0.38.28", "tracing", "windows-sys 0.52.0", ] @@ -1245,22 +1244,22 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.25" +version = "0.38.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc99bc2d4f1fed22595588a013687477aedf3cdcfb26558c559edb67b4d9b22e" +checksum = "72e572a5e8ca657d7366229cdde4bd14c4eb5499a9573d4d366fe1b599daa316" dependencies = [ "bitflags 2.4.1", "errno", "libc", - "linux-raw-sys 0.4.11", - "windows-sys 0.48.0", + "linux-raw-sys 0.4.12", + "windows-sys 0.52.0", ] [[package]] name = "ryu" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" [[package]] name = "serde" @@ -1289,7 +1288,7 @@ checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.41", ] [[package]] @@ -1311,7 +1310,7 @@ checksum = "3081f5ffbb02284dda55132aa26daecedd7372a42417bbbab6f14ab7d6bb9145" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.41", ] [[package]] @@ -1406,9 +1405,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.39" +version = "2.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +checksum = "44c8b28c477cc3bf0e7966561e3460130e1255f7a1cf71931075f1c5e7a7e269" dependencies = [ "proc-macro2", "quote", @@ -1424,7 +1423,7 @@ dependencies = [ "cfg-if", "fastrand 2.0.1", "redox_syscall", - "rustix 0.38.25", + "rustix 0.38.28", "windows-sys 0.48.0", ] @@ -1445,7 +1444,7 @@ checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.41", ] [[package]] @@ -1509,7 +1508,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.41", ] [[package]] @@ -1561,9 +1560,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" +checksum = "6f2528f27a9eb2b21e69c95319b30bd0efd85d09c379741b0f78ea1d86be2416" [[package]] name = "unicode-ident" @@ -1783,9 +1782,9 @@ checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" [[package]] name = "winnow" -version = "0.5.19" +version = "0.5.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829846f3e3db426d4cee4510841b71a8e58aa2a76b1132579487ae430ccd9c7b" +checksum = "6c830786f7720c2fd27a1a0e27a709dbd3c4d009b56d098fc742d4f4eab91fe2" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index cd7d5e0..4224b25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,7 @@ verbose_file_reads = "warn" [dependencies] clap = { version = "4.2", features = ["derive"] } color-eyre = "0.6" -docker-compose-types = "0.6.1" +docker-compose-types = "0.7.0" duration-str = { version = "0.7", default-features = false } indexmap = "2" ipnet = { version = "2.7", features = ["serde"] } From fbb2c0ce43369661a141cdca658a0fbcd567c966 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Wed, 13 Dec 2023 03:13:46 -0600 Subject: [PATCH 17/23] fix(compose): support `cap_drop`, `userns_mode`, and `group_add` service fields Fixes: #31 --- src/cli/container/podman.rs | 1 + src/cli/container/quadlet.rs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/cli/container/podman.rs b/src/cli/container/podman.rs index 8dd1046..a930555 100644 --- a/src/cli/container/podman.rs +++ b/src/cli/container/podman.rs @@ -694,6 +694,7 @@ impl TryFrom<&mut docker_compose_types::Service> for PodmanArgs { pid: value.pid.take(), ulimit, entrypoint, + group_add: mem::take(&mut value.group_add), stop_signal: value.stop_signal.take(), stop_timeout, dns: mem::take(&mut value.dns), diff --git a/src/cli/container/quadlet.rs b/src/cli/container/quadlet.rs index cda7c03..7925479 100644 --- a/src/cli/container/quadlet.rs +++ b/src/cli/container/quadlet.rs @@ -508,6 +508,7 @@ impl TryFrom<&mut ComposeService> for QuadletOptions { Ok(Self { cap_add: mem::take(&mut service.cap_add), name: service.container_name.take(), + cap_drop: mem::take(&mut service.cap_drop), publish, env, env_file, @@ -524,6 +525,7 @@ impl TryFrom<&mut ComposeService> for QuadletOptions { tmpfs, mount, user: service.user.take(), + userns: service.userns_mode.take(), expose: mem::take(&mut service.expose), log_driver: service .logging From b85640a0e54eef0c191521184f1f000376a02e87 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Wed, 13 Dec 2023 03:16:40 -0600 Subject: [PATCH 18/23] fix: serialize `PodmanArgs=` flags in kebab-case --- src/cli/kube.rs | 1 + src/cli/network.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/cli/kube.rs b/src/cli/kube.rs index 47bcd0e..5de2ca6 100644 --- a/src/cli/kube.rs +++ b/src/cli/kube.rs @@ -113,6 +113,7 @@ impl From for crate::quadlet::Kube { } #[derive(Args, Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] pub struct PodmanArgs { /// Add an annotation to the container or pod /// diff --git a/src/cli/network.rs b/src/cli/network.rs index 8b72411..a91fd0b 100644 --- a/src/cli/network.rs +++ b/src/cli/network.rs @@ -146,6 +146,7 @@ impl From for crate::quadlet::Network { } #[derive(Args, Serialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] struct PodmanArgs { /// Set network-scoped DNS resolver/nameserver for containers in this network /// From 09d1b8103af593143fecadb2ca34ce33e0c7e502 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Wed, 13 Dec 2023 04:09:44 -0600 Subject: [PATCH 19/23] docs(readme): podman v4.6.0 --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6cfe919..674f6a7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ You can also view the demo on [asciinema](https://asciinema.org/a/591369). ## Features -- Designed for podman v4.5.0 and newer +- Designed for podman v4.6.0 and newer - Supports the following podman commands: - `podman run` - `podman kube play` @@ -146,11 +146,9 @@ Alternatively, if you just want podlet to read a specific compose file you can u Podlet is not (yet) a validator for podman commands. Some podman options are incompatible with each other and most options require specific formatting and/or only accept certain values. However, a few options are fully parsed and validated in order to facilitate creating the quadlet file. -For the `kube play`, `network create`, and `volume create` commands, not all of podman's options are available as not all options are supported by quadlet. - When converting compose files, not all options are supported by podman/quadlet. This is especially true when converting to a pod as some options must be applied to the pod as a whole. If podlet encounters an unsupported option an error will be returned. You will have to remove or comment out unsupported options to proceed. -Podlet is meant to be used with podman v4.5.0 or newer. Some quadlet options are unavailable or behave differently with earlier versions of podman/quadlet. +Podlet is meant to be used with podman v4.6.0 or newer. Some quadlet options are unavailable or behave differently with earlier versions of podman/quadlet. ## Contribution From 199f6b9dbc470e0cf432b46eea16eb2d88874e77 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Wed, 13 Dec 2023 04:28:08 -0600 Subject: [PATCH 20/23] feat(compose): support volume `driver` field --- src/quadlet/volume.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quadlet/volume.rs b/src/quadlet/volume.rs index 81a8aed..08f4b5e 100644 --- a/src/quadlet/volume.rs +++ b/src/quadlet/volume.rs @@ -49,7 +49,6 @@ impl TryFrom for Volume { fn try_from(value: docker_compose_types::ComposeVolume) -> Result { let unsupported_options = [ - ("driver", value.driver.is_none()), ("external", value.external.is_none()), ("name", value.name.is_none()), ]; @@ -83,6 +82,7 @@ impl TryFrom for Volume { Ok(Self { label, + podman_args: value.driver.map(|driver| format!("--driver {driver}")), ..options.into() }) } From 805cacae9280d2611e38b3871e0401d034e68bc9 Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Thu, 14 Dec 2023 09:34:12 -0600 Subject: [PATCH 21/23] fix(compose): split `command` string When the command in converted to the `Exec=` quadlet option, it is now properly quoted. When converting to k8s, it is properly split into args. Fixes: #36 --- src/cli/compose.rs | 20 +++++++++++++++++++- src/cli/container.rs | 8 ++++---- src/cli/k8s.rs | 12 ++++++------ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/cli/compose.rs b/src/cli/compose.rs index 74956a8..b160a65 100644 --- a/src/cli/compose.rs +++ b/src/cli/compose.rs @@ -10,7 +10,7 @@ use color_eyre::{ eyre::{self, WrapErr}, Help, }; -use docker_compose_types::{Compose, ComposeNetworks, MapOrEmpty}; +use docker_compose_types::{Command, Compose, ComposeNetworks, MapOrEmpty}; use crate::quadlet; @@ -73,6 +73,24 @@ fn from_stdin() -> color_eyre::Result { serde_yaml::from_reader(stdin).wrap_err("data from stdin is not a valid compose file") } +/// Converts a [`Command`] into a `Vec`, splitting the `String` variant as a shell would. +/// +/// # Errors +/// +/// Returns an error if, while splitting the string variant, the command ends while in a quote or +/// has a trailing unescaped '\\'. +pub fn command_try_into_vec(command: Command) -> color_eyre::Result> { + match command { + Command::Simple(s) => shlex::split(&s) + .ok_or_else(|| eyre::eyre!("invalid command: `{s}`")) + .suggestion( + "In the command, make sure quotes are closed properly and there are no \ + trailing \\. Alternatively, use an array instead of a string.", + ), + Command::Args(args) => Ok(args), + } +} + /// Attempt to convert a [`Compose`] into an iterator of [`quadlet::File`]. pub fn try_into_quadlet_files<'a>( mut compose: Compose, diff --git a/src/cli/container.rs b/src/cli/container.rs index a76c81c..60dcfdd 100644 --- a/src/cli/container.rs +++ b/src/cli/container.rs @@ -7,6 +7,8 @@ use std::mem; use clap::Args; use color_eyre::eyre::{self, Context}; +use crate::cli::compose; + use self::{podman::PodmanArgs, quadlet::QuadletOptions, security_opt::SecurityOpt}; use super::{image_to_name, ComposeService}; @@ -89,10 +91,8 @@ impl TryFrom for Container { command: value .service .command - .map(|command| match command { - docker_compose_types::Command::Simple(s) => vec![s], - docker_compose_types::Command::Args(args) => args, - }) + .map(compose::command_try_into_vec) + .transpose()? .unwrap_or_default(), }) } diff --git a/src/cli/k8s.rs b/src/cli/k8s.rs index 8755e02..d47a711 100644 --- a/src/cli/k8s.rs +++ b/src/cli/k8s.rs @@ -5,8 +5,8 @@ use color_eyre::{ Help, }; use docker_compose_types::{ - AdvancedVolumes, Command, Compose, ComposeVolume, Entrypoint, Environment, Healthcheck, - HealthcheckTest, Labels, Ports, PublishedPort, Service, SingleValue, Tmpfs, Ulimit, Ulimits, + AdvancedVolumes, Compose, ComposeVolume, Entrypoint, Environment, Healthcheck, HealthcheckTest, + Labels, Ports, PublishedPort, Service, SingleValue, Tmpfs, Ulimit, Ulimits, Volumes as ComposeVolumes, }; use indexmap::IndexMap; @@ -132,10 +132,10 @@ fn service_try_into_container( ) .unzip(); - let args = service.command.map(|command| match command { - Command::Simple(command) => vec![command], - Command::Args(command) => command, - }); + let args = service + .command + .map(compose::command_try_into_vec) + .transpose()?; let command = service.entrypoint.map(|entrypoint| match entrypoint { Entrypoint::Simple(entrypoint) => vec![entrypoint], From cbb431a79cdf5575ddc49bb8897ab35428d0aaff Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Fri, 15 Dec 2023 01:51:51 -0600 Subject: [PATCH 22/23] docs(changelog): add `git-cliff` configuration --- Cargo.toml | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 4224b25..82794df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,3 +92,88 @@ targets = ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin", "x86_64-apple-dar installers = [] # Publish jobs to run in CI pr-run-mode = "plan" + +# Config for 'git cliff' +# Run with `git cliff --bump -c Cargo.toml -up CHANGELOG.md` +# https://git-cliff.org/docs/configuration +[workspace.metadata.git-cliff.changelog] +# changelog header +header = """ +# Changelog\n +""" +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif -%} + {% if commit.breaking %}[**breaking**] {% endif -%} + {{ commit.message | upper_first }}\ + {% for footer in commit.footers | filter(attribute="token", value="Fixes") -%} + {% raw %} {% endraw %}({{ footer.value }})\ + {% endfor -%} + {% endfor %} +{% endfor %}\n +""" +# remove the leading and trailing whitespace from the template +trim = true +# changelog footer +footer = """ + +""" +# postprocessors +postprocessors = [ + { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, # replace issue numbers + { pattern = '', replace = "https://github.com/k9withabone/podlet" }, + { pattern = '### \pN+ ', replace = "### " } # remove numbers from groups +] + +[workspace.metadata.git-cliff.git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "1 Features" }, + { body = ".*security", group = "2 Security" }, + { message = "^fix", group = "3 Bug Fixes" }, + { message = "^perf", group = "4 Performance" }, + { message = "^doc", group = "5 Documentation" }, + { message = "^test", group = "6 Testing" }, + { message = "^refactor", group = "7 Refactor" }, + { message = "^style", group = "8 Styling" }, + { message = "^chore|ci", group = "9 Miscellaneous Tasks" }, + { message = "^revert", group = "10 Revert" }, + { message = "^release", skip = true }, + { message = "^chore\\(deps\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, +] +# protect breaking changes from being skipped due to matching a skipping commit_parser +protect_breaking_commits = false +# filter out the commits that are not matched by commit parsers +filter_commits = false +# regex for matching git tags +tag_pattern = "v[0-9].*" + +# regex for skipping tags +skip_tags = "v0.1.0-beta.1" +# regex for ignoring tags +ignore_tags = "" +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" +# limit the number of commits included in the changelog. +# limit_commits = 42 From ac05144807d7a83d9187fa03445224ba6c2680ca Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Fri, 15 Dec 2023 02:35:40 -0600 Subject: [PATCH 23/23] release: podlet v0.2.2 --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed4dd2..6519feb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog +## [0.2.2] - 2023-12-15 + +### Features + +- Add support for quadlet options introduced in podman v4.6.0 ([#28](https://github.com/k9withabone/podlet/issues/28)) + - Container + - `Sysctl=` ([#22](https://github.com/k9withabone/podlet/pull/22), thanks [@b-rad15](https://github.com/b-rad15)!) + - `AutoUpdate=` + - `HostName=` + - `Pull=` + - `WorkingDir=` + - `SecurityLabelNested=` + - `Mask=` + - `Unmask=` + - Kube, Network, and Volume + - `PodmanArgs=` +- *(compose)* Support volume `driver` field + +### Bug Fixes + +- *(container)* Arg `--tls-verify` requires = +- *(network)* Filter out empty `Options=` quadlet option +- Escape newlines in joined quadlet values ([#32](https://github.com/k9withabone/podlet/issues/32)) +- *(compose)* Support `cap_drop`, `userns_mode`, and `group_add` service fields ([#31](https://github.com/k9withabone/podlet/issues/31), [#34](https://github.com/k9withabone/podlet/issues/34)) +- *(compose)* Split `command` string ([#36](https://github.com/k9withabone/podlet/issues/36)) + - When the command is converted to the `Exec=` quadlet option, it is now properly quoted. When converting to k8s, it is properly split into args. + +### Documentation + +- *(readme)* Podman v4.6.0 +- *(changelog)* Add `git-cliff` configuration + +### Refactor + +- Use custom serializer for `PodmanArgs=` +- Use custom serializer for quadlet sections + +### Miscellaneous Tasks + +- Update dependencies + ## [0.2.1] - 2023-11-28 ### Features diff --git a/Cargo.lock b/Cargo.lock index c128dcd..f05822c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1062,7 +1062,7 @@ dependencies = [ [[package]] name = "podlet" -version = "0.2.1" +version = "0.2.2" dependencies = [ "clap", "color-eyre", diff --git a/Cargo.toml b/Cargo.toml index 82794df..7c688fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "podlet" -version = "0.2.1" +version = "0.2.2" authors = ["Paul Nettleton "] edition = "2021" description = "Generate podman quadlet files from a podman command or a compose file"