commit 0632ed76590bf286bde80ab5a476d64009e0a673 Author: upstream Date: Fri Sep 11 22:51:27 2026 +0200 Upstream containers/podlet state of the touched files before e0bd37059e diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..f68337d --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,1002 @@ +mod artifact; +mod build; +mod compose; +mod container; +mod generate; +mod global_args; +mod image; +mod install; +mod k8s; +mod kube; +mod network; +mod pod; +pub mod volume; + +#[cfg(unix)] +mod systemd_dbus; + +use std::{ + borrow::Cow, + collections::HashSet, + env, + ffi::OsStr, + fs, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +use clap::{ArgAction, Parser, Subcommand, builder::TypedValueParser}; +use color_eyre::{ + Help, + eyre::{WrapErr, ensure, eyre}, +}; +use compose_spec::service::blkio_config::Weight; +use path_clean::PathClean; + +use crate::quadlet::{ + self, Downgrade, DowngradeError, GenericSections, Globals, HostPaths, JoinOption, + PodmanVersion, Quadlet, Service, Unit, +}; + +use self::{ + artifact::Artifact, build::Build, compose::Compose, container::Container, generate::Generate, + global_args::GlobalArgs, image::Image, install::Install, kube::Kube, network::Network, + pod::Pod, volume::Volume, +}; + +#[expect( + clippy::option_option, + clippy::struct_excessive_bools, + reason = "CLI args" +)] +#[derive(Parser, Debug, Clone, PartialEq)] +#[command(author, version, about, subcommand_precedence_over_arg = true)] +pub struct Cli { + /// Generate file(s) instead of printing to stdout + /// + /// Optionally provide a path for the file, + /// if no path is provided the file will be placed in the current working directory. + /// + /// If not provided, the name of the generated file will be taken from, + /// the `name` parameter for volumes and networks, + /// the filename of the kube file, + /// the container name, + /// or the name of the container image. + #[arg(short, long, groups = ["file_out", "file_or_unit_dir"])] + file: Option>, + + /// Generate file(s) in the Podman unit directory instead of printing to stdout + /// + /// Conflicts with the --file option + /// + /// Equivalent to `--file $XDG_CONFIG_HOME/containers/systemd/` for non-root users, + /// or `--file /etc/containers/systemd/` for root. + /// + /// The name of the file can be specified with the `--name` option. + #[arg( + short, + long, + visible_alias = "unit-dir", + conflicts_with = "file", + groups = ["file_out", "file_or_unit_dir"] + )] + unit_directory: bool, + + /// Generate a single `.quadlets` file instead of separate Quadlet files + /// + /// Provide the name (without the extension) that the `.quadlets` file should use. + /// + /// If `--file` or `--unit-directory` is not used, `--file` is implied with the current working + /// directory. + #[arg(long, value_name = "NAME", group = "file_out")] + quadlets_file: Option, + + /// Override the name of the generated file (without the extension) + /// + /// This only applies if a directory was given to `--file`, or `--unit-directory` was used. + /// + /// E.g. `podlet --file --name hello-world podman run quay.io/podman/hello` + /// will generate a file with the name "hello-world.container". + /// + /// Conflicts with the `--quadlets-file` option as the file name is set with it. + #[arg( + short, + long, + requires = "file_or_unit_dir", + conflicts_with = "quadlets_file" + )] + name: Option, + + /// Overwrite existing files when generating a file + /// + /// By default, Podlet will return an error if a file already exists at the given location. + #[arg(long, alias = "override", requires = "file_out")] + overwrite: bool, + + /// Split Quadlet options instead of joining them together. + /// + /// By default, Podlet will combine all Quadlet options that can be joined into a single line. + /// + /// For example, `podlet podman run -e ONE=one -e TWO=two image` results in: + /// + /// ```ini + /// # FileName=image + /// [Container] + /// Environment=ONE=one TWO=two + /// Image=image + /// ``` + /// + /// While `podlet -s Environment podman run -e ONE=one -e TWO=two image` results in: + /// + /// ```ini + /// # FileName=image + /// [Container] + /// Environment=ONE=one + /// Environment=TWO=two + /// Image=image + /// ``` + /// + /// Quadlet options can be specified in a comma (,) separated list and/or this option can be + /// specified multiple times. + // + // Maintenance Note: When updating the above help text, also update `Cli::SPLIT_OPTIONS_HELP`. + #[arg( + short, + long, + value_enum, + value_delimiter = ',', + value_name = "QUADLET_OPTION,...", + long_help = Self::SPLIT_OPTIONS_HELP + )] + split_options: Vec, + + /// Skip the check for existing services of the same name + /// + /// By default, Podlet will check for existing services with the same name as + /// the service Quadlet will generate from the generated Quadlet file + /// and return an error if a conflict is found. + /// This option will cause Podlet to skip that check. + #[arg(long, requires = "file_out")] + skip_services_check: bool, + + /// Podman version generated Quadlet files should conform to + /// + /// An error will occur if the Quadlet file cannot be downgraded to the given version. + /// + /// Always defaults to the latest supported Podman version which added Quadlet features. + /// If an earlier version is specified, the Quadlet file may not be the most optimal. + /// + /// This feature is only supported in a limited way. You should always check Quadlet files + /// generated with Podlet before running them. + #[arg(short, long, visible_aliases = ["compatibility", "compat"], default_value_t)] + podman_version: PodmanVersion, + + /// Convert relative host paths to absolute paths. + /// + /// Relative host paths in generated Quadlet files are resolved using the given directory or the + /// current working directory. For `podlet compose`, the parent directory of the compose + /// file is used as the default if the compose file is not read from stdin. + /// + /// All host paths are also cleaned to remove interior `/../`, `/./`, and `//`. + /// + /// When using `podlet compose --pod`, modifying paths in generated Kubernetes YAML files is not + /// supported. + /// + /// Note that only host paths not in the `PodmanArgs=` Quadlet option will be modified. + /// + /// Podlet will return an error if the current working directory cannot be read, or if the given + /// directory path is not absolute. + #[arg(short, long, value_name = "RESOLVE_DIR")] + absolute_host_paths: Option>, + + /// Change the name of the systemd service Quadlet generates. + /// + /// Converts to "ServiceName=SERVICE_NAME". + /// + /// The name should **not** include the `.service` extension. + /// + /// Podlet will error if this option is used while creating more than one Quadlet file as + /// setting the same service name for multiple files will conflict with each other. + #[expect(clippy::doc_markdown, reason = "Quadlet option")] + #[arg(long)] + service_name: Option, + + /// The \[Unit\] section + #[command(flatten)] + unit: Unit, + + /// The \[Install\] section + #[command(flatten)] + install: Install, + + /// Disable Quadlet's default network dependencies. + /// + /// By default, Quadlet adds a dependency on `network-online.target` (for system units) or + /// `podman-user-wait-network-online.service` (for user units) to the generated unit. Using this + /// flag will disable the dependencies. + /// + /// Converts to "DefaultDependencies=false" in the `[Quadlet]` section. + #[arg(long)] + disable_default_quadlet_dependencies: bool, + + /// Do not start container units with their associated pod. + /// + /// By default, container units are started alongside the pod. + /// + /// Converts to "StartWithPod=false" in the `[Container]` section. + #[arg(long)] + no_start_with_pod: bool, + + #[command(subcommand)] + command: Commands, +} + +impl Cli { + /// Long help for `podlet --split-options`. + /// + /// Set separately from doc comment to preserve formatting of examples. + const SPLIT_OPTIONS_HELP: &str = "\ +Split Quadlet options instead of joining them together. + +By default, Podlet will combine all Quadlet options that can be joined into a single line. + +For example, `podlet podman run -e ONE=one -e TWO=two image` results in: + +# FileName=image +[Container] +Environment=ONE=one TWO=two +Image=image + +While `podlet -s Environment podman run -e ONE=one -e TWO=two image` results in: + +# FileName=image +[Container] +Environment=ONE=one +Environment=TWO=two +Image=image + +Quadlet options can be specified in a comma (,) separated list and/or this option can be specified \ +multiple times."; + + pub fn print_or_write_files(self) -> color_eyre::Result<()> { + // Determine which Quadlet options to join together into a single line by subtracting the + // selected options from the set of all possible options. + let split_options = self.split_options.iter().copied().collect(); + let join_options = &JoinOption::all_set() - &split_options; + + if self.unit_directory || self.file.is_some() || self.quadlets_file.is_some() { + // file out + let path = self.file_path()?; + let quadlets_file = self.quadlets_file.clone(); + if matches!(path, FilePath::Full(_)) { + if quadlets_file.is_some() { + return Err(eyre!( + "A file path was provided to `--file` and `--quadlets-file` was used`" + ) + .note("The file name is set by `--quadlets-file`.") + .suggestion("Provide a directory to `--file`.")); + } + if matches!(self.command, Commands::Compose(_)) { + return Err(eyre!( + "A file path was provided to `--file` and the `compose` command was used" + ) + .warning("`compose` can generate multiple files so a directory is needed.") + .suggestion("Provide a directory to `--file`.")); + } + } + if quadlets_file.is_some() + && matches!(self.command, Commands::Compose(Compose { kube: true, .. })) + { + return Err( + eyre!("cannot set both `--quadlets-file` and `compose --kube`") + .warning("`.quadlets` files cannot include Kubernetes YAML in them."), + ); + } + + let overwrite = self.overwrite; + #[cfg(unix)] + let services_check = !self.skip_services_check; + + let files = self.try_into_files()?; + + #[cfg(unix)] + if services_check { + check_existing( + files.iter().filter_map(File::as_quadlet_file), + &path, + overwrite, + )?; + } + + if let Some(quadlets_file) = quadlets_file { + let contents = files_to_quadlets_file(&files, &join_options)?; + let path = path.to_full(&quadlets_file, "quadlets"); + + open_file(&path, overwrite)? + .write_all(contents.as_bytes()) + .wrap_err_with(|| format!("error writing to file `{}`", path.display()))?; + + println!("Wrote to file: {}", path.display()); + } else { + for file in files { + file.write(&path, overwrite, &join_options)?; + } + } + + Ok(()) + } else { + let files = self.try_into_files()?; + let files = files_to_quadlets_file(&files, &join_options)?; + print!("{files}"); + Ok(()) + } + } + + /// Returns the file path for the generated file + fn file_path(&self) -> color_eyre::Result { + let path = if self.unit_directory { + #[cfg(unix)] + if rustix::process::getuid().is_root() { + let path = PathBuf::from("/etc/containers/systemd/"); + if path.is_dir() { + path + } else { + PathBuf::from("/usr/share/containers/systemd/") + } + } else { + let mut path: PathBuf = env::var("XDG_CONFIG_HOME") + .or_else(|_| env::var("HOME").map(|home| format!("{home}/.config"))) + .unwrap_or_else(|_| String::from("~/.config/")) + .into(); + path.push("containers/systemd/"); + path + } + + #[cfg(not(unix))] + color_eyre::eyre::bail!("Cannot get Podman unit directory on non-Unix system"); + } else if let Some(Some(path)) = &self.file { + if path.is_dir() { + path.clone() + } else { + return Ok(FilePath::Full(path.clone())); + } + } else { + env::current_dir() + .wrap_err("File path not provided and can't access current directory")? + }; + + Ok(FilePath::Dir(path)) + } + + /// Take the directory to resolve relative paths with. + /// + /// Returns [`None`] if relative paths should not be resolved. + /// + /// # Errors + /// + /// Returns an error if the resolve directory is not absolute or, when needed, the current + /// working directory could not be read. + fn resolve_dir(&mut self) -> color_eyre::Result> { + const CURRENT_DIR_ERR: &str = "current working directory could not be read"; + + self.absolute_host_paths + .take() + .map(|path| { + if let Some(path) = path { + ensure!( + path.is_absolute(), + "path `{}` is not absolute", + path.display() + ); + Ok(path) + } else { + match &self.command { + Commands::Compose(Compose { + compose_file: Some(path), + .. + }) if path.as_os_str() != "-" && !path.as_os_str().is_empty() => { + if let Some(path) = path.parent() { + let current_dir = env::current_dir().wrap_err(CURRENT_DIR_ERR)?; + Ok(absolute_clean_path(¤t_dir, path)) + } else { + // path is the root directory + Ok(path.to_owned()) + } + } + _ => env::current_dir().wrap_err(CURRENT_DIR_ERR), + } + } + }) + .transpose() + } + + /// Convert into [`File`]s + fn try_into_files(mut self) -> color_eyre::Result> { + let resolve_dir = self + .resolve_dir() + .wrap_err("error with `--absolute-host-paths` resolve directory")?; + + let sections = GenericSections { + unit: self.unit, + quadlet: Quadlet { + default_dependencies: !self.disable_default_quadlet_dependencies, + }, + install: self.install.into(), + }; + + let mut files = self.command.try_into_files(self.name, sections)?; + + if let Some(service_name) = self.service_name { + let mut found_quadlet_file = false; + for _ in files + .iter() + .filter(|file| matches!(file, File::Quadlet(..))) + { + if found_quadlet_file { + return Err(eyre!( + "cannot set `--service-name` when creating more than one Quadlet file" + )) + .note( + "setting the same service name for multiple Quadlet files will conflict \ + with each other", + ) + .suggestion("manually set `ServiceName=` Quadlet option in each Quadlet file"); + } + found_quadlet_file = true; + } + + if let Some(File::Quadlet(file)) = files.first_mut() { + file.globals.service_name = Some(service_name); + } + } + + let downgrade = self.podman_version < PodmanVersion::LATEST; + if resolve_dir.is_some() || self.no_start_with_pod || downgrade { + for file in &mut files { + if let Some(resolve_dir) = &resolve_dir { + file.absolutize_host_paths(resolve_dir); + } + + if self.no_start_with_pod { + file.set_start_with_pod(false); + } + + if downgrade { + file.downgrade(self.podman_version).wrap_err_with(|| { + format!( + "error downgrading Quadlet file to Podman v{}", + self.podman_version + ) + })?; + } + } + } + + Ok(files) + } +} + +/// [`PathBuf`] pointing to a file or directory +#[derive(Debug)] +enum FilePath { + /// [`PathBuf`] pointing to a file + Full(PathBuf), + /// [`PathBuf`] pointing to a directory + Dir(PathBuf), +} + +impl FilePath { + /// Convert to full file path + /// + /// If `self` is a directory, `filename` and `extension` are used to set the filename. + fn to_full(&self, filename: &str, extension: &str) -> Cow<'_, Path> { + match self { + Self::Full(path) => path.into(), + Self::Dir(path) => { + let mut path = path.join(filename); + path.set_extension(extension); + path.into() + } + } + } +} + +/// Serialize each [`File`] and join them together in the `.quadlets` file format. +/// +/// # Errors +/// +/// Returns an error if a file fails to serialize. +fn files_to_quadlets_file<'a>( + files: impl IntoIterator, + join_options: &HashSet, +) -> color_eyre::Result { + files + .into_iter() + .map(|file| { + let file_name = file.name(); + + let serialized_file = file.serialize(join_options).wrap_err_with(|| { + format!("error serializing {} file `{file_name}`", file.extension()) + })?; + + let mut content = String::with_capacity(serialized_file.capacity()); + content.push_str("# "); + + match file { + File::Quadlet(_) => { + content.push_str("FileName="); + content.push_str(file_name); + } + File::Kubernetes(_) => { + content.push_str(file_name); + content.push_str(".yaml"); + } + } + + content.push('\n'); + content.push_str(&serialized_file); + + Ok(content) + }) + .collect::>>() + .map(|files| files.join("\n---\n\n")) +} + +#[derive(Subcommand, Debug, Clone, PartialEq)] +enum Commands { + /// Generate a Podman Quadlet file from a Podman command + Podman { + #[command(flatten)] + global_args: Box, + + #[command(subcommand)] + command: PodmanCommands, + }, + + /// Generate Podman Quadlet files from a compose file + /// + /// Creates a `.container` file for each service, + /// a `.volume` file for each volume (if it has additional options set), + /// and a `.network` file for each network. + /// + /// The `--file` option must be a directory if used. + /// + /// Some compose options are not supported, such as `build`. + /// + /// When Podlet encounters an unsupported option, an error will be returned. + /// Modify the compose file to resolve the error. + Compose(#[command(flatten)] Compose), + + /// Generate a Podman Quadlet file from an existing object. + /// + /// Note: these commands require that Podman is installed and is searchable + /// from the `PATH` environment variable. + #[command(subcommand)] + Generate(Generate), +} + +impl Commands { + /// Attempt to convert the input into [`File`]s + /// + /// # Errors + /// + /// Returns an error if there was an error converting from the compose file or existing object. + fn try_into_files( + self, + name: Option, + sections: GenericSections, + ) -> color_eyre::Result> { + match self { + Self::Podman { + global_args, + command, + } => Ok(vec![ + command + .into_quadlet(name, sections, (*global_args).into()) + .into(), + ]), + Self::Compose(compose) => compose + .try_into_files(sections) + .wrap_err("error converting compose file"), + Self::Generate(command) => Ok(command + .try_into_quadlet_files(name, sections) + .wrap_err("error creating Quadlet file(s) from an existing object")? + .into_iter() + .map(Into::into) + .collect()), + } + } +} + +#[allow(clippy::doc_markdown)] +#[derive(Subcommand, Debug, Clone, PartialEq)] +enum PodmanCommands { + /// Generate a Podman Quadlet `.container` file + /// + /// For details on options see: + /// https://docs.podman.io/en/stable/markdown/podman-systemd.unit.5.html + #[command(disable_help_flag = true)] + Run { + /// The \[Container\] section + #[command(flatten)] + container: Box, + + /// The \[Service\] section + #[command(flatten)] + service: Service, + + /// Print help + // Changed from default to support `podman run -h`, i.e. `podman run --hostname`. + #[arg( + short = '?', + long, + action = ArgAction::Help, + help = "Print help (see more with '--help')", + long_help = "Print help (see a summary with '-?')" + )] + help: (), + }, + + /// Generate a Podman Quadlet `.pod` file + /// + /// For details on options see: + /// https://docs.podman.io/en/stable/markdown/podman-pod-create.1.html + Pod { + /// The \[Pod\] section + #[command(subcommand)] + pod: Box, + }, + + /// Generate a Podman Quadlet `.kube` file + /// + /// For details on options see: + /// https://docs.podman.io/en/stable/markdown/podman-kube-play.1.html + Kube { + /// The \[Kube\] section + #[command(subcommand)] + kube: Box, + }, + + /// Generate a Podman Quadlet `.network` file + /// + /// For details on options see: + /// https://docs.podman.io/en/stable/markdown/podman-network-create.1.html + Network { + /// The \[Network\] section + #[command(subcommand)] + network: Box, + }, + + /// Generate a Podman Quadlet `.volume` file + /// + /// For details on options see: + /// https://docs.podman.io/en/stable/markdown/podman-volume-create.1.html + Volume { + /// The \[Volume\] section + #[command(subcommand)] + volume: Volume, + }, + + /// Generate a Podman Quadlet `.build` file + /// + /// For details on options see: + /// https://docs.podman.io/en/stable/markdown/podman-build.1.html + Build { + /// The \[Build\] section + #[command(flatten)] + build: Box, + }, + + /// Generate a Podman Quadlet `.image` file + /// + /// For details on options see: + /// https://docs.podman.io/en/stable/markdown/podman-pull.1.html + Image { + /// The \[Image\] section + #[command(subcommand)] + image: Box, + }, + + /// Generate a Podman Quadlet `.artifact` file + /// + /// For details on options see: + /// https://docs.podman.io/en/stable/markdown/podman-artifact-pull.1.html + Artifact { + /// The \[Artifact\] section + #[command(subcommand)] + artifact: Artifact, + }, +} + +impl From for quadlet::Resource { + fn from(value: PodmanCommands) -> Self { + match value { + PodmanCommands::Run { container, .. } => (*container).into(), + PodmanCommands::Pod { pod } => (*pod).into(), + PodmanCommands::Kube { kube } => (*kube).into(), + PodmanCommands::Network { network } => (*network).into(), + PodmanCommands::Volume { volume } => volume.into(), + PodmanCommands::Build { build } => (*build).into(), + PodmanCommands::Image { image } => (*image).into(), + PodmanCommands::Artifact { artifact } => artifact.into(), + } + } +} + +impl PodmanCommands { + /// Convert the Podman command into a [`quadlet::File`]. + fn into_quadlet( + self, + name: Option, + GenericSections { + unit, + quadlet, + install, + }: GenericSections, + globals: Globals, + ) -> quadlet::File { + let service = self.service().cloned().unwrap_or_default(); + quadlet::File { + name: name.unwrap_or_else(|| self.name().into()), + unit, + resource: self.into(), + globals, + quadlet, + service, + install, + } + } + + fn service(&self) -> Option<&Service> { + match self { + Self::Run { service, .. } => Some(service), + _ => None, + } + } + + /// Returns the name that should be used for the generated file + fn name(&self) -> &str { + match self { + Self::Run { container, .. } => container.name(), + Self::Pod { pod } => pod.name(), + Self::Kube { kube } => kube.name(), + Self::Network { network } => network.name(), + Self::Volume { volume } => volume.name(), + Self::Build { build } => build.name(), + Self::Image { image } => image.name(), + Self::Artifact { artifact } => artifact.name(), + } + } +} + +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] // false positive, [Pod] is not zero-sized +enum File { + Quadlet(quadlet::File), + Kubernetes(k8s::File), +} + +impl From for File { + fn from(value: quadlet::File) -> Self { + Self::Quadlet(value) + } +} + +impl From for File { + fn from(value: k8s::File) -> Self { + Self::Kubernetes(value) + } +} + +impl File { + fn name(&self) -> &str { + match self { + Self::Quadlet(file) => &file.name, + Self::Kubernetes(file) => &file.name, + } + } + + fn extension(&self) -> &str { + match self { + Self::Quadlet(file) => file.resource.extension(), + Self::Kubernetes(_) => "yaml", + } + } + + /// Returns [`Some`] if a [`File::Quadlet`]. + fn as_quadlet_file(&self) -> Option<&quadlet::File> { + match self { + Self::Quadlet(file) => Some(file), + Self::Kubernetes(_) => None, + } + } + + /// Returns [`Some`] if a [`File::Quadlet`]. + fn as_quadlet_file_mut(&mut self) -> Option<&mut quadlet::File> { + match self { + Self::Quadlet(file) => Some(file), + Self::Kubernetes(_) => None, + } + } + + /// If this [`File`] is a Quadlet container unit, set the `StartWithPod=` Quadlet option. + fn set_start_with_pod(&mut self, start_with_pod: bool) { + if let Self::Quadlet(file) = self { + file.set_start_with_pod(start_with_pod); + } + } + + /// If a Quadlet file, make all host paths absolute and clean. + /// + /// Relative paths are resolved using `resolve_dir` as the base. + fn absolutize_host_paths(&mut self, resolve_dir: &Path) { + for path in self.host_paths() { + *path = absolute_clean_path(resolve_dir, path); + } + } + + /// Serialize this [`File`] to a [`String`] in the proper format. + /// + /// Quadlet options in `join_options` are joined together with a space. + /// + /// # Errors + /// + /// Returns an error if the contained [`quadlet::File`] or [`k8s::File`] returns an error while + /// serializing. + fn serialize(&self, join_options: &HashSet) -> color_eyre::Result { + match self { + File::Quadlet(file) => file + .serialize_to_quadlet(join_options) + .wrap_err("error serializing Quadlet file"), + File::Kubernetes(file) => file + .serialize_to_yaml() + .wrap_err("error serializing Kubernetes YAML file"), + } + } + + /// Serialize and write this file to `path`, optionally overwriting any already existing file. + /// + /// During serialization, Quadlet options in `join_options` are joined together with a space. + /// + /// # Errors + /// + /// Returns an error if there is an error while serializing or when writing the file. + fn write( + &self, + path: &FilePath, + overwrite: bool, + join_options: &HashSet, + ) -> color_eyre::Result<()> { + let path = path.to_full(self.name(), self.extension()); + let mut file = open_file(&path, overwrite)?; + let path = path.display(); + + let contents = self + .serialize(join_options) + .wrap_err_with(|| format!("error serializing contents for file: {path}"))?; + + file.write_all(contents.as_bytes()) + .wrap_err_with(|| format!("failed to write to file: {path}"))?; + + println!("Wrote to file: {path}"); + + Ok(()) + } +} + +/// If `path` is relative, it is resolved using `resolve_dir` and a cleaned version is returned. +fn absolute_clean_path(resolve_dir: &Path, path: &Path) -> PathBuf { + // Paths starting with "%" are also absolute because they start with a systemd specifier. + let path: Cow = if path.is_absolute() || path.starts_with("%") { + path.into() + } else { + resolve_dir.join(path).into() + }; + + path.clean() +} + +fn open_file(path: impl AsRef, overwrite: bool) -> color_eyre::Result { + fs::File::options() + .write(true) + .truncate(true) + .create_new(!overwrite) + .create(overwrite) + .open(&path) + .map_err(|error| { + let path = path.as_ref().display(); + match error.kind() { + io::ErrorKind::AlreadyExists => { + eyre!("File already exists, not overwriting it: {path}") + .suggestion("Use `--overwrite` if you wish overwrite existing files.") + } + _ => color_eyre::Report::new(error) + .wrap_err(format!("Failed to create/open file: {path}")) + .suggestion( + "Make sure the directory exists \ + and you have write permissions for the file", + ), + } + }) +} + +impl HostPaths for File { + fn host_paths(&mut self) -> impl Iterator { + self.as_quadlet_file_mut() + .into_iter() + .flat_map(quadlet::File::host_paths) + } +} + +impl Downgrade for File { + fn downgrade(&mut self, version: PodmanVersion) -> Result<(), DowngradeError> { + match self { + Self::Quadlet(file) => file.downgrade(version), + Self::Kubernetes(_) => Ok(()), + } + } +} + +/// Takes an image and returns an appropriate default service name +fn image_to_name(image: &str) -> &str { + let image = image + .rsplit('/') + .next() + .expect("Split will have at least one element"); + // Remove image tag + image.split_once(':').map_or(image, |(name, _)| name) +} + +#[cfg(unix)] +fn check_existing<'a>( + quadlet_files: impl IntoIterator, + path: &FilePath, + overwrite: bool, +) -> color_eyre::Result<()> { + if let Ok(unit_files) = systemd_dbus::unit_files().map(Iterator::collect::>) { + let file_names: Vec<_> = quadlet_files + .into_iter() + .filter_map(|file| match &path { + FilePath::Full(path) => path.file_stem().and_then(OsStr::to_str).map(|name| { + let service = file.resource.name_to_service(name); + (name, service) + }), + FilePath::Dir(_) => Some((file.name.as_str(), file.service_name())), + }) + .collect(); + for systemd_dbus::UnitFile { file_name, status } in unit_files { + for (name, service) in &file_names { + if !(overwrite && status == "generated") && file_name.contains(service) { + return Err(eyre!( + "File name `{name}` conflicts with existing unit file: {file_name}" + ) + .suggestion( + "Change the generated file's name with `--file` or `--name`. \ + Alternatively, use `--skip-services-check` if this is ok.", + )); + } + } + } + } + + Ok(()) +} + +/// Create a [`TypedValueParser`] for parsing a `blkio_weight` field. +fn blkio_weight_parser() -> impl TypedValueParser { + clap::value_parser!(u16) + .range(10..=1000) + .try_map(TryInto::try_into) +} + +#[cfg(test)] +mod tests { + use clap::CommandFactory; + + use super::*; + + #[test] + fn verify_cli() { + Cli::command().debug_assert(); + } +}