diff --git a/Cargo.lock b/Cargo.lock index 02d0b46..5592b67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -853,7 +853,7 @@ dependencies = [ [[package]] name = "podlet" -version = "0.3.2-alpha.2" +version = "0.3.2-alpha.3" dependencies = [ "clap", "color-eyre", diff --git a/Cargo.toml b/Cargo.toml index 15b7bc5..b57b95f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "podlet" -version = "0.3.2-alpha.2" +version = "0.3.2-alpha.3" authors = ["Paul Nettleton "] edition = "2024" rust-version = "1.85" diff --git a/src/cli/build.rs b/src/cli/build.rs index f29cc94..f2f94f9 100644 --- a/src/cli/build.rs +++ b/src/cli/build.rs @@ -148,6 +148,22 @@ pub struct Build { #[arg(long, value_name = "POLICY")] pull: Option, + /// Number of times to retry pulling images from the registry in case of failure. + /// + /// Converts to "Retry=ATTEMPTS". + /// + /// Default is 3. + #[arg(long, value_name = "ATTEMPTS")] + retry: Option, + + /// Duration of delay between retry attempts. + /// + /// Converts to "RetryDelay=DURATION". + /// + /// Default is to start at two seconds and then exponentially back off. + #[arg(long, value_name = "DURATION")] + retry_delay: Option, + /// Pass secret information in a safe way to the build container. /// /// Converts to "Secret=id=ID,src=PATH". @@ -221,6 +237,8 @@ impl From for quadlet::Build { label, network, pull, + retry, + retry_delay, secret, target, tls_verify, @@ -248,6 +266,8 @@ impl From for quadlet::Build { network, podman_args: (!podman_args.is_empty()).then_some(podman_args), pull, + retry, + retry_delay, secret, set_working_directory: context, target, @@ -575,6 +595,12 @@ struct PodmanArgs { #[arg(long, value_name = "IMAGE_ID_FILE")] iidfile: Option, + /// Inherit the labels from the base image or base stages. + #[arg(long, action = ArgAction::Set, default_value_t = true)] + #[serde(skip_serializing_if = "skip_true")] + #[default = true] + inherit_labels: bool, + /// Sets the configuration for IPC namespaces when handling `RUN` instructions. #[arg(long, value_name = "HOW")] ipc: Option, @@ -673,14 +699,6 @@ struct PodmanArgs { #[serde(skip_serializing_if = "Not::not")] quiet: bool, - /// Number of times to retry pulling images from the registry in case of failure. - #[arg(long, value_name = "ATTEMPTS")] - retry: Option, - - /// Duration of delay between retry attempts. - #[arg(long, value_name = "DURATION")] - retry_delay: Option, - /// Remove intermediate containers after a successful build. #[arg(long, action = ArgAction::Set, default_value_t = true)] #[serde(skip_serializing_if = "skip_true")] diff --git a/src/cli/container/compose.rs b/src/cli/container/compose.rs index 60e3740..fe04834 100644 --- a/src/cli/container/compose.rs +++ b/src/cli/container/compose.rs @@ -166,6 +166,7 @@ impl From for Service { labels, log_driver, log_options, + mem_limit, network_config, pids_limit, ports, @@ -198,7 +199,6 @@ impl From for Service { ipc, uts, mac_address, - mem_limit, mem_reservation, mem_swappiness, memswap_limit, @@ -324,6 +324,7 @@ pub struct Quadlet { pub labels: ListOrMap, pub log_driver: Option, pub log_options: IndexMap>, + pub mem_limit: Option, pub network_config: Option, pub pids_limit: Option>, pub ports: Ports, @@ -359,7 +360,6 @@ pub struct PodmanArgs { pub ipc: Option, pub uts: Option, pub mac_address: Option, - pub mem_limit: Option, pub mem_reservation: Option, pub mem_swappiness: Option, pub memswap_limit: Option>, diff --git a/src/cli/container/podman.rs b/src/cli/container/podman.rs index c78a082..78d3a44 100644 --- a/src/cli/container/podman.rs +++ b/src/cli/container/podman.rs @@ -219,10 +219,6 @@ pub struct PodmanArgs { #[arg(long, value_name = "ADDRESS")] mac_address: Option, - /// Memory limit - #[arg(short, long, value_name = "NUMBER[UNIT]")] - memory: Option, - /// Memory soft limit #[arg(long, value_name = "NUMBER[UNIT]")] memory_reservation: Option, @@ -330,18 +326,6 @@ pub struct PodmanArgs { #[arg(long, value_name = "CONTAINER[,...]")] requires: Option, - /// Number of times to retry pulling or pushing images between the registry and local storage - /// - /// Default is 3 - #[arg(long, value_name = "ATTEMPTS")] - retry: Option, - - /// Duration of delay between retry attempts when pulling or pushing images - /// - /// Default is to start at two seconds and then exponentially back off - #[arg(long, value_name = "DURATION")] - retry_delay: Option, - /// Remove container (and pod if created) after exit /// /// Automatically set by Quadlet @@ -439,7 +423,6 @@ impl TryFrom for PodmanArgs { ipc, uts, mac_address, - mem_limit, mem_reservation, mem_swappiness, memswap_limit, @@ -504,7 +487,6 @@ impl TryFrom for PodmanArgs { .wrap_err("`ipc` invalid")?, uts: uts.as_ref().map(ToString::to_string), mac_address: mac_address.as_ref().map(ToString::to_string), - memory: mem_limit.as_ref().map(ToString::to_string), memory_reservation: mem_reservation.as_ref().map(ToString::to_string), memory_swap: memswap_limit, memory_swappiness: mem_swappiness.map(Into::into), diff --git a/src/cli/container/quadlet.rs b/src/cli/container/quadlet.rs index b0ebaed..bdfcd5c 100644 --- a/src/cli/container/quadlet.rs +++ b/src/cli/container/quadlet.rs @@ -296,6 +296,12 @@ pub struct QuadletOptions { #[arg(long, value_name = "NAME=VALUE")] log_opt: Vec, + /// Memory limit + /// + /// Converts to "Memory=NUMBER[UNIT]" + #[arg(short, long, value_name = "NUMBER[UNIT]")] + memory: Option, + /// Attach a filesystem mount to the container /// /// Converts to "Mount=MOUNT" @@ -369,6 +375,22 @@ pub struct QuadletOptions { #[default = true] read_only_tmpfs: bool, + /// Number of times to retry pulling or pushing images between the registry and local storage + /// + /// Converts to "Retry=ATTEMPTS" + /// + /// Default is 3 + #[arg(long, value_name = "ATTEMPTS")] + retry: Option, + + /// Duration of delay between retry attempts when pulling or pushing images + /// + /// Converts to "RetryDelay=DURATION" + /// + /// Default is to start at two seconds and then exponentially back off + #[arg(long, value_name = "DURATION")] + retry_delay: Option, + /// The rootfs to use for the container /// /// Converts to "Rootfs=PATH" @@ -548,6 +570,7 @@ impl From for crate::quadlet::Container { log_driver, log_opt, mount, + memory, network, network_alias, sdnotify: notify, @@ -556,6 +579,8 @@ impl From for crate::quadlet::Container { pull, read_only, read_only_tmpfs, + retry, + retry_delay, rootfs, init: run_init, secret, @@ -626,6 +651,7 @@ impl From for crate::quadlet::Container { label, log_driver, log_opt, + memory, mount, network, network_alias, @@ -635,6 +661,8 @@ impl From for crate::quadlet::Container { pull, read_only, read_only_tmpfs, + retry, + retry_delay, rootfs, run_init, secret, @@ -683,6 +711,7 @@ impl TryFrom for QuadletOptions { labels, log_driver, log_options, + mem_limit, network_config, pids_limit, ports, @@ -788,6 +817,7 @@ impl TryFrom for QuadletOptions { option }) .collect(), + memory: mem_limit.as_ref().map(ToString::to_string), network: network_config .map(network_config_try_into_network_options) .transpose() diff --git a/src/cli/global_args.rs b/src/cli/global_args.rs index 806e2f2..d56139a 100644 --- a/src/cli/global_args.rs +++ b/src/cli/global_args.rs @@ -13,6 +13,14 @@ use crate::quadlet::Globals; #[command(next_help_heading = "Podman Global Options")] #[serde(rename_all = "kebab-case")] pub struct GlobalArgs { + /// CDI spec directory path + /// + /// Default is `/etc/cdi` + /// + /// Can be specified multiple times + #[arg(long, global = true, value_name = "PATH")] + cdi_spec_dir: Vec, + /// Cgroup manager to use #[arg(long, global = true, value_name = "MANAGER")] cgroup_manager: Option, diff --git a/src/cli/image.rs b/src/cli/image.rs index b409f80..bcf45f4 100644 --- a/src/cli/image.rs +++ b/src/cli/image.rs @@ -107,6 +107,23 @@ pub struct Pull { #[arg(long, conflicts_with_all = ["os", "arch"], value_name = "OS/ARCH")] pub platform: Option, + /// Number of times to retry pulling or pushing images between the registry and local storage. + /// + /// Converts to "Retry=ATTEMPTS". + /// + /// Default is 3. + #[arg(long, value_name = "ATTEMPTS")] + #[arg(long)] + pub retry: Option, + + /// Duration of delay between retry attempts when pulling or pushing images. + /// + /// Converts to "RetryDelay=DURATION". + /// + /// Default is to start at two seconds and then exponentially back off. + #[arg(long, value_name = "DURATION")] + pub retry_delay: Option, + /// Require HTTPS and verify certificates when contacting registries /// /// Converts to "TLSVerify=TLS_VERIFY" @@ -137,6 +154,8 @@ impl From for quadlet::Image { disable_content_trust: _, os, platform, + retry, + retry_delay, tls_verify, variant, source: image, @@ -157,6 +176,8 @@ impl From for quadlet::Image { image_tag: None, os, podman_args: None, + retry, + retry_delay, tls_verify, variant, } diff --git a/src/cli/install.rs b/src/cli/install.rs index 29c0f7e..11e121a 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -3,7 +3,7 @@ use clap::Args; #[allow(clippy::doc_markdown)] #[derive(Args, Debug, Clone, PartialEq)] pub struct Install { - /// Add an [Install] section to the unit + /// Add an [Install] section to the unit. /// /// By default, if the --wanted-by and --required-by options are not used, /// the section will have "WantedBy=default.target". @@ -11,25 +11,35 @@ pub struct Install { #[arg(short, long)] pub install: bool, - /// Add (weak) parent dependencies to the unit + /// Add (weak) parent dependencies to the unit. /// - /// Requires the --install option + /// Requires the --install option. /// - /// Converts to "WantedBy=WANTED_BY" + /// Converts to "WantedBy=WANTED_BY". /// - /// Can be specified multiple times + /// Can be specified multiple times. #[arg(long, requires = "install")] wanted_by: Vec, - /// Similar to --wanted-by, but adds stronger parent dependencies + /// Similar to --wanted-by, but adds stronger parent dependencies. /// - /// Requires the --install option + /// Requires the --install option. /// - /// Converts to "RequiredBy=REQUIRED_BY" + /// Converts to "RequiredBy=REQUIRED_BY". /// - /// Can be specified multiple times + /// Can be specified multiple times. #[arg(long, requires = "install")] required_by: Vec, + + /// Similar to --wanted-by, but ensures this unit is up if the parent dependency is. + /// + /// Requires the --install option. + /// + /// Converts to "UpheldBy=UPHELD_BY". + /// + /// Can be specified multiple times. + #[arg(long, requires = "install")] + upheld_by: Vec, } impl From for crate::quadlet::Install { @@ -38,15 +48,21 @@ impl From for crate::quadlet::Install { install, wanted_by, required_by, + upheld_by, }: Install, ) -> Self { Self { - wanted_by: if install && wanted_by.is_empty() && required_by.is_empty() { + wanted_by: if install + && wanted_by.is_empty() + && required_by.is_empty() + && upheld_by.is_empty() + { vec![String::from("default.target")] } else { wanted_by }, required_by, + upheld_by, } } } diff --git a/src/cli/pod.rs b/src/cli/pod.rs index f010580..bd4feb0 100644 --- a/src/cli/pod.rs +++ b/src/cli/pod.rs @@ -117,6 +117,12 @@ pub struct Create { )] gidmap: Vec, + /// Set the hostname of the pod. + /// + /// Converts to "HostName=NAME". + #[arg(long, value_name = "NAME")] + hostname: Option, + /// Specify a static IPv4 address for the pod. /// /// Converts to "IP=IPV4". @@ -248,6 +254,7 @@ impl From for quadlet::Pod { dns_option, dns_search, gidmap, + hostname, ip, ip6, network, @@ -273,6 +280,7 @@ impl From for quadlet::Pod { dns_option, dns_search, gid_map: gidmap, + host_name: hostname, ip, ip6, network, @@ -356,10 +364,6 @@ struct PodmanArgs { #[arg(long, value_name = "ENTRY")] gpus: Vec, - /// Set the hostname of the pod. - #[arg(long, value_name = "NAME")] - hostname: Option, - /// Base file to create the `/etc/hosts` file inside the pod's containers. #[arg(long, value_name = "PATH | none | image")] hosts_file: Option, diff --git a/src/quadlet.rs b/src/quadlet.rs index 2a3b5c0..becb926 100644 --- a/src/quadlet.rs +++ b/src/quadlet.rs @@ -12,7 +12,7 @@ mod volume; use std::{ collections::HashSet, - fmt::{self, Display, Formatter}, + fmt::{self, Display, Formatter, Write}, iter, path::PathBuf, str::FromStr, @@ -205,6 +205,12 @@ pub enum JoinOption { /// `Unmask=`, used in [Container] sections. Unmask, + /// `UpheldBy=`, used in [Install] sections. + UpheldBy, + + /// `Upholds=`, used in [Unit] sections. + Upholds, + /// `WantedBy=`, used in [Install] sections. WantedBy, @@ -229,6 +235,8 @@ impl JoinOption { Self::Requires, Self::Sysctl, Self::Unmask, + Self::UpheldBy, + Self::Upholds, Self::WantedBy, Self::Wants, ]; @@ -255,6 +263,8 @@ impl JoinOption { Self::Requires => "Requires", Self::Sysctl => "Sysctl", Self::Unmask => "Unmask", + Self::UpheldBy => "UpheldBy", + Self::Upholds => "Upholds", Self::WantedBy => "WantedBy", Self::Wants => "Wants", } @@ -286,6 +296,8 @@ impl FromStr for JoinOption { "Requires" => Ok(Self::Requires), "Sysctl" => Ok(Self::Sysctl), "Unmask" => Ok(Self::Unmask), + "UpheldBy" => Ok(Self::UpheldBy), + "Upholds" => Ok(Self::Upholds), "WantedBy" => Ok(Self::WantedBy), "Wants" => Ok(Self::Wants), s => Err(ParseJoinOptionError(s.to_owned())), @@ -597,13 +609,17 @@ pub enum PodmanVersion { V5_3, /// Podman v5.4 - #[value(name = "5.4", aliases = ["latest", "5.4.0", "5.4.1", "5.4.2"])] + #[value(name = "5.4", aliases = ["5.4.0", "5.4.1", "5.4.2"])] V5_4, + + /// Podman v5.5 + #[value(name = "5.5", aliases = ["latest", "5.5.0", "5.5.1", "5.5.2"])] + V5_5, } impl PodmanVersion { /// Latest supported version of Podman with regards to Quadlet. - pub const LATEST: Self = Self::V5_4; + pub const LATEST: Self = Self::V5_5; /// Podman version as a static string slice. pub const fn as_str(self) -> &'static str { @@ -618,6 +634,7 @@ impl PodmanVersion { Self::V5_2 => "5.2", Self::V5_3 => "5.3", Self::V5_4 => "5.4", + Self::V5_5 => "5.5", } } } @@ -775,3 +792,33 @@ impl HostPaths for Context { .into_iter() } } + +/// Add "--{flag} {arg}" to `podman_args`, quoting whitespace in `arg`. +fn push_arg(podman_args: &mut String, flag: &str, arg: &str) { + if !podman_args.is_empty() { + podman_args.push(' '); + } + + podman_args.push_str("--"); + podman_args.push_str(flag); + podman_args.push(' '); + + if arg.contains(char::is_whitespace) { + podman_args.push('"'); + podman_args.push_str(arg); + podman_args.push('"'); + } else { + podman_args.push_str(arg); + } +} + +/// Add "--{flag} {arg}" to `podman_args` using `arg`'s [`Display`] implementation. +/// +/// Prefer using [`push_arg()`] as it quotes whtiespace. +fn push_arg_display(podman_args: &mut String, flag: &str, arg: impl Display) { + if !podman_args.is_empty() { + podman_args.push(' '); + } + + write!(podman_args, "--{flag} {arg}").expect("write to String cannot fail"); +} diff --git a/src/quadlet/build.rs b/src/quadlet/build.rs index 3e9096e..6d84772 100644 --- a/src/quadlet/build.rs +++ b/src/quadlet/build.rs @@ -14,6 +14,7 @@ use crate::serde::{quadlet::seq_quote_whitespace, skip_true}; use super::{ Downgrade, DowngradeError, HostPaths, PodmanVersion, ResourceKind, container::{Dns, PullPolicy}, + push_arg, push_arg_display, }; /// Options for the \[Build\] section of a `.build` Quadlet file. @@ -73,6 +74,12 @@ pub struct Build { /// Set the image pull policy. pub pull: Option, + /// Number of times to retry the image pull when a HTTP error occurs. + pub retry: Option, + + /// Delay between retries. + pub retry_delay: Option, + /// Pass secret information used in Containerfile build stages in a safe way. pub secret: Vec, @@ -105,6 +112,16 @@ impl HostPaths for Build { impl Downgrade for Build { fn downgrade(&mut self, version: PodmanVersion) -> Result<(), DowngradeError> { + if version < PodmanVersion::V5_5 { + if let Some(retry) = self.retry.take() { + self.push_arg_display("retry", retry); + } + + if let Some(retry_delay) = self.retry_delay.take() { + self.push_arg("retry-delay", &retry_delay); + } + } + if version < PodmanVersion::V5_3 && self.image_tag.len() > 1 { return Err(DowngradeError::Multiple { quadlet_option: "ImageTag", @@ -123,6 +140,22 @@ impl Downgrade for Build { } } +impl Build { + /// Add `--{flag} {arg}` to `PodmanArgs=`. + fn push_arg(&mut self, flag: &str, arg: &str) { + let podman_args = self.podman_args.get_or_insert_default(); + push_arg(podman_args, flag, arg); + } + + /// Add `--{flag} {arg}` to `PodmanArgs=`. + /// + /// Ensure `arg` does not contain whitespace. + fn push_arg_display(&mut self, flag: &str, arg: impl Display) { + let podman_args = self.podman_args.get_or_insert_default(); + push_arg_display(podman_args, flag, arg); + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Secret { id: String, diff --git a/src/quadlet/container.rs b/src/quadlet/container.rs index 858322b..c34afb9 100644 --- a/src/quadlet/container.rs +++ b/src/quadlet/container.rs @@ -22,7 +22,7 @@ use crate::serde::{quadlet::seq_quote_whitespace, serialize_display_seq, skip_tr pub use self::{device::Device, mount::Mount, rootfs::Rootfs, volume::Volume}; -use super::{AutoUpdate, Downgrade, DowngradeError, HostPaths, PodmanVersion}; +use super::{AutoUpdate, Downgrade, DowngradeError, HostPaths, PodmanVersion, push_arg_display}; #[allow(clippy::struct_excessive_bools)] #[derive(Serialize, SmartDefault, Debug, Clone, PartialEq)] @@ -169,6 +169,9 @@ pub struct Container { #[serde(serialize_with = "seq_quote_whitespace")] pub mask: Vec, + /// Specify the amount of memory for the container. + pub memory: Option, + /// Attach a filesystem mount to the container. #[serde(serialize_with = "serialize_display_seq")] pub mount: Vec, @@ -214,6 +217,12 @@ pub struct Container { #[default = true] pub read_only_tmpfs: bool, + /// Number of times to retry the image pull when a HTTP error occurs. + pub retry: Option, + + /// Delay between retries. + pub retry_delay: Option, + /// The rootfs to use for the container. pub rootfs: Option, @@ -306,69 +315,16 @@ pub struct Container { impl Downgrade for Container { fn downgrade(&mut self, version: PodmanVersion) -> Result<(), DowngradeError> { + if version < PodmanVersion::V5_5 { + self.remove_v5_5_options()?; + } + if version < PodmanVersion::V5_4 { - self.mount.iter().try_for_each(|mount| { - if let Mount::Volume(mount::Volume { - subpath: Some(_), .. - }) = mount - { - Err(DowngradeError::Option { - quadlet_option: "Mount", - value: mount.to_string(), - supported_version: PodmanVersion::V5_4, - }) - } else { - Ok(()) - } - })?; + self.remove_v5_4_options()?; } if version < PodmanVersion::V5_3 { - if let Some(health_log_destination) = self.health_log_destination.take() { - return Err(DowngradeError::Option { - quadlet_option: "HealthLogDestination", - value: health_log_destination, - supported_version: PodmanVersion::V5_3, - }); - } - - if let Some(health_max_log_count) = self.health_max_log_count.take() { - return Err(DowngradeError::Option { - quadlet_option: "HealthMaxLogCount", - value: health_max_log_count.to_string(), - supported_version: PodmanVersion::V5_3, - }); - } - - if let Some(health_max_log_size) = self.health_max_log_size.take() { - return Err(DowngradeError::Option { - quadlet_option: "HealthMaxLogSize", - value: health_max_log_size.to_string(), - supported_version: PodmanVersion::V5_3, - }); - } - - if !self.start_with_pod { - return Err(DowngradeError::Option { - quadlet_option: "StartWithPod", - value: "false".to_owned(), - supported_version: PodmanVersion::V5_3, - }); - } - - self.network.iter().try_for_each(|network| { - if network.ends_with(".container") { - Err(DowngradeError::Option { - quadlet_option: "Network", - value: network.clone(), - supported_version: PodmanVersion::V5_3, - }) - } else { - Ok(()) - } - })?; - - self.remove_v5_3_options(); + self.remove_v5_3_options()?; } if version < PodmanVersion::V5_2 { @@ -380,27 +336,7 @@ impl Downgrade for Container { } if version < PodmanVersion::V5_0 { - if self.notify.is_healthy() { - if version < PodmanVersion::V4_7 { - return Err(DowngradeError::Option { - quadlet_option: "Notify", - value: "healthy".to_owned(), - supported_version: PodmanVersion::V4_7, - }); - } - self.notify = Notify::default(); - self.push_arg("sdnotify", "healthy"); - } - - if let Some(pod) = self.pod.take() { - return Err(DowngradeError::Option { - quadlet_option: "Pod", - value: pod, - supported_version: PodmanVersion::V5_0, - }); - } - - self.remove_v5_0_options(); + self.remove_v5_0_options(version)?; } if version < PodmanVersion::V4_8 { @@ -433,8 +369,99 @@ macro_rules! extract { } impl Container { + /// Remove Quadlet options added in Podman v5.5.0 + fn remove_v5_5_options(&mut self) -> Result<(), DowngradeError> { + self.mount.iter().try_for_each(|mount| { + if let Mount::Artifact(_) = mount { + Err(DowngradeError::Option { + quadlet_option: "Mount", + value: mount.to_string(), + supported_version: PodmanVersion::V5_5, + }) + } else { + Ok(()) + } + })?; + + let options = extract!( + self, + OptionsV5_5 { + memory, + retry, + retry_delay, + } + ); + + self.push_args(options) + .expect("OptionsV5_5 serializable as args"); + + Ok(()) + } + + /// Remove Quadlet options added in Podman v5.4.0 + fn remove_v5_4_options(&mut self) -> Result<(), DowngradeError> { + self.mount.iter().try_for_each(|mount| { + if let Mount::Volume(mount::Volume { + subpath: Some(_), .. + }) = mount + { + Err(DowngradeError::Option { + quadlet_option: "Mount", + value: mount.to_string(), + supported_version: PodmanVersion::V5_4, + }) + } else { + Ok(()) + } + }) + } + /// Remove Quadlet options added in Podman v5.3.0 - fn remove_v5_3_options(&mut self) { + fn remove_v5_3_options(&mut self) -> Result<(), DowngradeError> { + if let Some(health_log_destination) = self.health_log_destination.take() { + return Err(DowngradeError::Option { + quadlet_option: "HealthLogDestination", + value: health_log_destination, + supported_version: PodmanVersion::V5_3, + }); + } + + if let Some(health_max_log_count) = self.health_max_log_count.take() { + return Err(DowngradeError::Option { + quadlet_option: "HealthMaxLogCount", + value: health_max_log_count.to_string(), + supported_version: PodmanVersion::V5_3, + }); + } + + if let Some(health_max_log_size) = self.health_max_log_size.take() { + return Err(DowngradeError::Option { + quadlet_option: "HealthMaxLogSize", + value: health_max_log_size.to_string(), + supported_version: PodmanVersion::V5_3, + }); + } + + if !self.start_with_pod { + return Err(DowngradeError::Option { + quadlet_option: "StartWithPod", + value: "false".to_owned(), + supported_version: PodmanVersion::V5_3, + }); + } + + self.network.iter().try_for_each(|network| { + if network.ends_with(".container") { + Err(DowngradeError::Option { + quadlet_option: "Network", + value: network.clone(), + supported_version: PodmanVersion::V5_3, + }) + } else { + Ok(()) + } + })?; + let options = extract!( self, OptionsV5_3 { @@ -445,6 +472,8 @@ impl Container { self.push_args(options) .expect("OptionsV5_3 serializable as args"); + + Ok(()) } /// Remove Quadlet options added in Podman v5.2.0 @@ -471,7 +500,27 @@ impl Container { } /// Remove Quadlet options added in Podman v5.0.0 - fn remove_v5_0_options(&mut self) { + fn remove_v5_0_options(&mut self, version: PodmanVersion) -> Result<(), DowngradeError> { + if self.notify.is_healthy() { + if version < PodmanVersion::V4_7 { + return Err(DowngradeError::Option { + quadlet_option: "Notify", + value: "healthy".to_owned(), + supported_version: PodmanVersion::V4_7, + }); + } + self.notify = Notify::default(); + self.push_arg("sdnotify", "healthy"); + } + + if let Some(pod) = self.pod.take() { + return Err(DowngradeError::Option { + quadlet_option: "Pod", + value: pod, + supported_version: PodmanVersion::V5_0, + }); + } + let options = extract!( self, OptionsV5_0 { @@ -482,6 +531,8 @@ impl Container { self.push_args(options) .expect("OptionsV5_0 serializable as args"); + + Ok(()) } /// Remove Quadlet options added in Podman v4.8.0 @@ -599,13 +650,16 @@ impl Container { } /// Add `--{flag} {arg}` to `PodmanArgs=`. + /// + /// Ensure `arg` does not contain whitespace. fn push_arg(&mut self, flag: &str, arg: impl Display) { - self.podman_args_push_str(&format!("--{flag} {arg}")); + let podman_args = self.podman_args.get_or_insert_default(); + push_arg_display(podman_args, flag, arg); } /// Push `string` to `podman_args`, adding a space if needed. fn podman_args_push_str(&mut self, string: &str) { - let podman_args = self.podman_args.get_or_insert_with(String::new); + let podman_args = self.podman_args.get_or_insert_default(); if !podman_args.is_empty() { podman_args.push(' '); } @@ -613,6 +667,15 @@ impl Container { } } +/// Container Quadlet options added in Podman v5.5.0 +#[derive(Serialize, Debug)] +#[serde(rename_all = "kebab-case")] +struct OptionsV5_5 { + memory: Option, + retry: Option, + retry_delay: Option, +} + /// Container Quadlet options added in Podman v5.3.0 #[derive(Serialize, Debug)] #[serde(rename_all = "kebab-case")] diff --git a/src/quadlet/container/mount.rs b/src/quadlet/container/mount.rs index 82511e6..5d9e982 100644 --- a/src/quadlet/container/mount.rs +++ b/src/quadlet/container/mount.rs @@ -38,6 +38,7 @@ pub use self::{idmap::Idmap, tmpfs::Tmpfs}; #[derive(Serialize, Debug, Clone, PartialEq, Eq)] #[serde(tag = "type", rename_all = "lowercase")] pub enum Mount { + Artifact(Artifact), Bind(Bind), DevPts(DevPts), Glob(Bind), @@ -51,6 +52,7 @@ pub enum Mount { #[derive(Deserialize, Debug, Clone, Copy)] #[serde(rename_all = "lowercase")] enum MountType { + Artifact, Bind, DevPts, Glob, @@ -77,6 +79,7 @@ impl MountType { let map = MapAccessDeserializer::new(map); match_type_deserialize! { self, map, + Artifact => Artifact, Bind => Bind, DevPts => DevPts, Glob => Bind, @@ -120,7 +123,7 @@ impl<'de> Visitor<'de> for MountVisitor { impl Display for Mount { fn fmt(&self, f: &mut Formatter) -> fmt::Result { - let mount = mount_options::to_string(self).map_err(|_| fmt::Error)?; + let mount = mount_options::to_string(self).expect("mount serialization cannot fail"); f.write_str(&mount) } } @@ -155,7 +158,8 @@ impl HostPaths for Mount { fn host_paths(&mut self) -> impl Iterator { match self { Self::Bind(bind) | Self::Glob(bind) => Some(&mut bind.source), - Self::DevPts(_) + Self::Artifact(_) + | Self::DevPts(_) | Self::Image(_) | Self::Ramfs(_) | Self::Tmpfs(_) @@ -165,6 +169,102 @@ impl HostPaths for Mount { } } +/// Artifact type [`Mount`]. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(into = "ArtifactFlat", try_from = "ArtifactFlat")] +pub struct Artifact { + /// Mount source spec. + pub source: String, + + /// Mount destination spec. + pub destination: PathBuf, + + /// If the artifact contains multiple blobs, the digest or title of the blob to use. + pub digest_or_title: Option, +} + +/// A flattened version of [`Artifact`] for (de)serialization. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct ArtifactFlat { + /// Mount source spec. + #[serde(alias = "src")] + source: String, + + /// Mount destination spec. + #[serde(alias = "dst", alias = "target")] + destination: PathBuf, + + /// If the artifact contains multiple blobs, the digest to use. + #[serde(default, skip_serializing_if = "Option::is_none")] + digest: Option, + + /// If the artifact contains multiple blobs, the title to use. + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option, +} + +impl From for ArtifactFlat { + fn from( + Artifact { + source, + destination, + digest_or_title, + }: Artifact, + ) -> Self { + let (digest, title) = match digest_or_title { + Some(DigestOrTitle::Digest(digest)) => (Some(digest), None), + Some(DigestOrTitle::Title(title)) => (None, Some(title)), + None => (None, None), + }; + + Self { + source, + destination, + digest, + title, + } + } +} + +impl TryFrom for Artifact { + type Error = &'static str; + + fn try_from( + ArtifactFlat { + source, + destination, + digest, + title, + }: ArtifactFlat, + ) -> Result { + let digest_or_title = match (digest, title) { + (Some(digest), None) => Some(DigestOrTitle::Digest(digest)), + (None, Some(title)) => Some(DigestOrTitle::Title(title)), + (None, None) => None, + (Some(_), Some(_)) => return Err("cannot set both `digest` and `title`"), + }; + + Ok(Self { + source, + destination, + digest_or_title, + }) + } +} + +/// The `digest` or `title` field of the [`Artifact`] [`Mount`] type. +/// +/// Used if an artifact contains more than one blob to select the blob to mount. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DigestOrTitle { + /// The digest to select the artifact blob with. + Digest(String), + + /// The title to select the artifact blob with. + Title(String), +} + /// Bind or glob type [`Mount`]. #[allow(clippy::struct_field_names, clippy::struct_excessive_bools)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] @@ -446,6 +546,58 @@ impl Volume { mod tests { use super::*; + #[test] + fn artifact() -> Result<(), ParseMountError> { + // No digest or title + let string = "type=artifact,source=artifact,destination=/dst"; + let mount: Mount = string.parse()?; + assert_eq!( + mount, + Mount::Artifact(Artifact { + source: "artifact".to_owned(), + destination: "/dst".into(), + digest_or_title: None + }) + ); + assert_eq!(mount.to_string(), string); + + // Digest + let string = "type=artifact,source=artifact,destination=/dst,digest=digest"; + let mount: Mount = string.parse()?; + assert_eq!( + mount, + Mount::Artifact(Artifact { + source: "artifact".to_owned(), + destination: "/dst".into(), + digest_or_title: Some(DigestOrTitle::Digest("digest".to_owned())), + }) + ); + assert_eq!(mount.to_string(), string); + + // Title + let string = "type=artifact,source=artifact,destination=/dst,title=title"; + let mount: Mount = string.parse()?; + assert_eq!( + mount, + Mount::Artifact(Artifact { + source: "artifact".to_owned(), + destination: "/dst".into(), + digest_or_title: Some(DigestOrTitle::Title("title".to_owned())), + }) + ); + assert_eq!(mount.to_string(), string); + + // Digest and title is error + assert!( + Mount::from_str( + "type=artifact,source=artifact,destination=/dst,digest=digest,title=title" + ) + .is_err() + ); + + Ok(()) + } + #[test] fn bind() -> Result<(), ParseMountError> { let string = "type=bind,source=/src,destination=/dst"; diff --git a/src/quadlet/image.rs b/src/quadlet/image.rs index fa9c1c3..5cad27c 100644 --- a/src/quadlet/image.rs +++ b/src/quadlet/image.rs @@ -8,7 +8,9 @@ use std::{ use serde::{Serialize, Serializer}; -use super::{Downgrade, DowngradeError, HostPaths, PodmanVersion, ResourceKind}; +use super::{ + Downgrade, DowngradeError, HostPaths, PodmanVersion, ResourceKind, push_arg, push_arg_display, +}; #[derive(Serialize, Debug, Clone, PartialEq)] #[serde(rename_all = "PascalCase")] @@ -49,6 +51,12 @@ pub struct Image { /// generated file. pub podman_args: Option, + /// Number of times to retry the image pull when a HTTP error occurs. + pub retry: Option, + + /// Delay between retries. + pub retry_delay: Option, + /// Require HTTPS and verification of certificates when contacting registries. #[serde(rename = "TLSVerify")] pub tls_verify: Option, @@ -74,6 +82,32 @@ impl HostPaths for Image { impl Downgrade for Image { #[allow(clippy::unused_self)] fn downgrade(&mut self, version: PodmanVersion) -> Result<(), DowngradeError> { + if version < PodmanVersion::V5_5 { + if let Some(retry) = self.retry.take() { + // `podman image pull --retry` was added in Podman v5.0.0 + if version < PodmanVersion::V5_0 { + return Err(DowngradeError::Option { + quadlet_option: "Retry", + value: retry.to_string(), + supported_version: PodmanVersion::V5_0, + }); + } + self.push_arg_display("retry", retry); + } + + if let Some(retry_delay) = self.retry_delay.take() { + // `podman image pull --retry-delay` was added in Podman v5.0.0 + if version < PodmanVersion::V5_0 { + return Err(DowngradeError::Option { + quadlet_option: "RetryDelay", + value: retry_delay, + supported_version: PodmanVersion::V5_0, + }); + } + self.push_arg("retry-delay", &retry_delay); + } + } + if version < PodmanVersion::V4_8 { return Err(DowngradeError::Kind { kind: ResourceKind::Image, @@ -85,6 +119,22 @@ impl Downgrade for Image { } } +impl Image { + /// Add `--{flag} {arg}` to `PodmanArgs=`. + fn push_arg(&mut self, flag: &str, arg: &str) { + let podman_args = self.podman_args.get_or_insert_default(); + push_arg(podman_args, flag, arg); + } + + /// Add `--{flag} {arg}` to `PodmanArgs=`. + /// + /// Ensure `arg` does not contain whitespace. + fn push_arg_display(&mut self, flag: &str, arg: impl Display) { + let podman_args = self.podman_args.get_or_insert_default(); + push_arg_display(podman_args, flag, arg); + } +} + /// The key and optional passphrase for decryption of images. /// /// See the `--decryption-key` section of diff --git a/src/quadlet/install.rs b/src/quadlet/install.rs index c043090..23d969f 100644 --- a/src/quadlet/install.rs +++ b/src/quadlet/install.rs @@ -3,6 +3,7 @@ use serde::Serialize; use crate::serde::quadlet::seq_quote_whitespace; /// The `[Install]` section of a systemd unit / Quadlet file. +#[expect(clippy::struct_field_names, reason = "systemd directives")] #[derive(Serialize, Default, Debug, Clone, PartialEq)] #[serde(rename_all = "PascalCase")] pub struct Install { @@ -13,6 +14,10 @@ pub struct Install { /// Add stronger parent dependencies to the unit. #[serde(serialize_with = "seq_quote_whitespace")] pub required_by: Vec, + + /// Add stronger parent dependencies to the unit. + #[serde(serialize_with = "seq_quote_whitespace")] + pub upheld_by: Vec, } impl Install { @@ -21,8 +26,9 @@ impl Install { let Self { wanted_by, required_by, + upheld_by, } = self; - wanted_by.is_empty() && required_by.is_empty() + wanted_by.is_empty() && required_by.is_empty() && upheld_by.is_empty() } } diff --git a/src/quadlet/kube.rs b/src/quadlet/kube.rs index a194727..ae54702 100644 --- a/src/quadlet/kube.rs +++ b/src/quadlet/kube.rs @@ -9,7 +9,7 @@ use std::{ use serde::{Serialize, Serializer}; use url::Url; -use super::{Downgrade, DowngradeError, HostPaths, PodmanVersion}; +use super::{Downgrade, DowngradeError, HostPaths, PodmanVersion, push_arg}; #[derive(Serialize, Debug, Clone, PartialEq)] #[serde(rename_all = "PascalCase")] @@ -59,14 +59,8 @@ impl Kube { /// Add `--{flag} {arg}` to `PodmanArgs=`. fn push_arg(&mut self, flag: &str, arg: &str) { - let podman_args = self.podman_args.get_or_insert_with(String::new); - if !podman_args.is_empty() { - podman_args.push(' '); - } - podman_args.push_str("--"); - podman_args.push_str(flag); - podman_args.push(' '); - podman_args.push_str(arg); + let podman_args = self.podman_args.get_or_insert_default(); + push_arg(podman_args, flag, arg); } } diff --git a/src/quadlet/network.rs b/src/quadlet/network.rs index efd99d4..0cfe1c7 100644 --- a/src/quadlet/network.rs +++ b/src/quadlet/network.rs @@ -12,7 +12,7 @@ use thiserror::Error; use crate::serde::quadlet::seq_quote_whitespace; -use super::{Downgrade, DowngradeError, PodmanVersion}; +use super::{Downgrade, DowngradeError, PodmanVersion, push_arg}; #[derive(Serialize, Debug, Default, Clone, PartialEq)] #[serde(rename_all = "PascalCase")] @@ -65,14 +65,8 @@ pub struct Network { impl Network { /// Add `--{flag} {arg}` to `PodmanArgs=`. fn push_arg(&mut self, flag: &str, arg: &str) { - let podman_args = self.podman_args.get_or_insert_with(String::new); - if !podman_args.is_empty() { - podman_args.push(' '); - } - podman_args.push_str("--"); - podman_args.push_str(flag); - podman_args.push(' '); - podman_args.push_str(arg); + let podman_args = self.podman_args.get_or_insert_default(); + push_arg(podman_args, flag, arg); } } diff --git a/src/quadlet/pod.rs b/src/quadlet/pod.rs index a6bb0b7..7359db5 100644 --- a/src/quadlet/pod.rs +++ b/src/quadlet/pod.rs @@ -8,6 +8,7 @@ use serde::Serialize; use super::{ Downgrade, DowngradeError, HostPaths, PodmanVersion, ResourceKind, container::{Dns, Volume}, + push_arg, }; /// Options for the \[Pod\] section of a `.pod` Quadlet file. @@ -33,6 +34,9 @@ pub struct Pod { #[serde(rename = "GIDMap")] pub gid_map: Vec, + /// Set the pod’s hostname inside all containers. + pub host_name: Option, + /// Specify a static IPv4 address for the pod. #[serde(rename = "IP")] pub ip: Option, @@ -91,6 +95,12 @@ impl HostPaths for Pod { impl Downgrade for Pod { fn downgrade(&mut self, version: PodmanVersion) -> Result<(), DowngradeError> { + if version < PodmanVersion::V5_5 { + if let Some(host_name) = self.host_name.take() { + self.push_arg("hostname", &host_name); + } + } + if version < PodmanVersion::V5_4 { if let Some(shm_size) = self.shm_size.take() { self.push_arg("shm-size", &shm_size); @@ -173,19 +183,7 @@ impl Pod { /// Add `--{flag} {arg}` to `PodmanArgs=`. fn push_arg(&mut self, flag: &str, arg: &str) { - let podman_args = self.podman_args.get_or_insert_with(String::new); - if !podman_args.is_empty() { - podman_args.push(' '); - } - podman_args.push_str("--"); - podman_args.push_str(flag); - podman_args.push(' '); - if arg.contains(char::is_whitespace) { - podman_args.push('"'); - podman_args.push_str(arg); - podman_args.push('"'); - } else { - podman_args.push_str(arg); - } + let podman_args = self.podman_args.get_or_insert_default(); + push_arg(podman_args, flag, arg); } } diff --git a/src/quadlet/unit.rs b/src/quadlet/unit.rs index 9150804..7b64e18 100644 --- a/src/quadlet/unit.rs +++ b/src/quadlet/unit.rs @@ -52,32 +52,41 @@ pub struct Unit { #[serde(serialize_with = "seq_quote_whitespace")] pub binds_to: Vec, - /// Configure ordering dependency between units. + /// Similar to --binds-to, but this unit only stops when the dependency is explicitly stopped. /// - /// Converts to "Before=BEFORE[ ...]". + /// Converts to "PartOf=PART_OF[ ...]". /// /// Can be specified multiple times. #[arg(long)] #[serde(serialize_with = "seq_quote_whitespace")] - pub before: Vec, + pub part_of: Vec, + + /// Similar to --wants, but dependencies are continuously started when inactive or failed. + /// + /// Converts to "Upholds=UPHOLDS[ ...]". + /// + /// Can be specified multiple times. + #[arg(long)] + #[serde(serialize_with = "seq_quote_whitespace")] + pub upholds: Vec, /// Configure ordering dependency between units. /// - /// Converts to "After=AFTER[ ...]". + /// Converts to "Before=BEFORE[ ...]". /// /// Can be specified multiple times. #[arg(long)] #[serde(serialize_with = "seq_quote_whitespace")] - pub after: Vec, + pub before: Vec, - /// Similar to --binds-to, but this unit only stops when the dependency is explicitly stopped + /// Configure ordering dependency between units. /// - /// Converts to "PartOf=PART_OF[ ...]" + /// Converts to "After=AFTER[ ...]". /// - /// Can be specified multiple times + /// Can be specified multiple times. #[arg(long)] #[serde(serialize_with = "seq_quote_whitespace")] - pub part_of: Vec, + pub after: Vec, } impl Unit { @@ -88,18 +97,20 @@ impl Unit { wants, requires, binds_to, + part_of, + upholds, before, after, - part_of, } = self; description.is_none() && wants.is_empty() && requires.is_empty() && binds_to.is_empty() + && part_of.is_empty() + && upholds.is_empty() && before.is_empty() && after.is_empty() - && part_of.is_empty() } /// Add a compose [`Service`](compose_spec::Service) [`Dependency`] to the unit. diff --git a/src/quadlet/volume.rs b/src/quadlet/volume.rs index 418b908..f09b298 100644 --- a/src/quadlet/volume.rs +++ b/src/quadlet/volume.rs @@ -5,7 +5,7 @@ use serde::Serialize; use crate::{cli::volume::Opt, serde::quadlet::seq_quote_whitespace}; -use super::{Downgrade, DowngradeError, HostPaths, PodmanVersion}; +use super::{Downgrade, DowngradeError, HostPaths, PodmanVersion, push_arg}; #[derive(Serialize, Debug, Default, Clone, PartialEq)] #[serde(rename_all = "PascalCase")] @@ -55,14 +55,8 @@ impl HostPaths for Volume { impl Volume { /// Add `--{flag} {arg}` to `PodmanArgs=`. fn push_arg(&mut self, flag: &str, arg: &str) { - let podman_args = self.podman_args.get_or_insert_with(String::new); - if !podman_args.is_empty() { - podman_args.push(' '); - } - podman_args.push_str("--"); - podman_args.push_str(flag); - podman_args.push(' '); - podman_args.push_str(arg); + let podman_args = self.podman_args.get_or_insert_default(); + push_arg(podman_args, flag, arg); } }