Re: [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files

Hannes Duerr
2026-02-25T15:13:13Z

Please consider this:
Reviewed-by: Hannes Dürr <h.duerr@proxmox.com>
Tested-by: Hannes Dürr <h.duerr@proxmox.com>

On 2/4/26 1:10 PM, Christoph Heiss wrote:
> Adds a new flag to the `prepare-iso` subcommand; `--pxe`.
>
> When specified, instead of just creating the usual
> auto-installation-capable ISO, additionally `vmlinuz` and `initrd.img`
> will be created, which can be used in combination with the ISO to
> PXE-boot our installer.
>
> The (generated) iPXE configuration gives a good overlook how these files
> must be used, especially what kernel commandline flags must be
> additionally given.
>
> Tl;dr is: The `vmlinuz` is directly booted and given the `initrd.img` as
> ramdisk, the ISO file must be overlaid as `/proxmox.iso`. This will
> cause init to mount & load the rest of the installer from there.
>
> The flag also takes an optional argument specifying the specific PXE
> bootloader to generate a configuration file for.
>
> Currently only iPXE is supported for configuration generation.
>
> Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
> ---
> FWIW, I'm also working on PXELINUX support, but that has a few quirks.
>
> Tested this with a TFTP server and default iPXE. I.e. copying
> `boot.ipxe`, `vmlinuz`, `initrd.img` and the `.iso` file to the TFTP
> server root, and then booting off it using the iPXE commandline using:
>
> $ dhcp
> $ chain --autofree tftp://<ip-of-tftp-server>/boot.ipxe
>
> In addition, tested this with OPNsense set up such that it automatically
> instructs iPXE to load `boot.ipxe` from the set TFTP server, completely
> automating an installation from booting the machine to being able to
> log into the finished system.
>
> debian/control | 2 +
> proxmox-auto-install-assistant/Cargo.toml | 2 +
> proxmox-auto-install-assistant/src/main.rs | 489 +++++++++++++++++++--
> proxmox-installer-common/src/cli.rs | 2 +-
> proxmox-installer-common/src/setup.rs | 11 +
> 5 files changed, 480 insertions(+), 26 deletions(-)
>
> diff --git a/debian/control b/debian/control
> index 9659924..757db94 100644
> --- a/debian/control
> +++ b/debian/control
> @@ -13,6 +13,7 @@ Build-Depends: cargo:native,
> librsvg2-bin,
> librust-anyhow-1-dev,
> librust-cursive-0.21+crossterm-backend-dev,
> + librust-flate2-1.1-dev,
> librust-glob-0.3-dev,
> librust-hex-0.4-dev,
> librust-native-tls-dev,
> @@ -28,6 +29,7 @@ Build-Depends: cargo:native,
> librust-sha2-0.10-dev,
> librust-toml-0.8-dev,
> librust-ureq-3-dev,
> + librust-zstd-0.13-dev,
> libtest-mockmodule-perl,
> patchelf,
> perl,
> diff --git a/proxmox-auto-install-assistant/Cargo.toml b/proxmox-auto-install-assistant/Cargo.toml
> index eeba42f..9a61fb5 100644
> --- a/proxmox-auto-install-assistant/Cargo.toml
> +++ b/proxmox-auto-install-assistant/Cargo.toml
> @@ -19,3 +19,5 @@ toml.workspace = true
>
> proxmox-sys = { version = "1.0.0", features = [ "crypt" ] }
> glob = "0.3"
> +flate2 = "1.1"
> +zstd = { version = "0.13", default-features = false }
> diff --git a/proxmox-auto-install-assistant/src/main.rs b/proxmox-auto-install-assistant/src/main.rs
> index af5b703..ca2a4f9 100644
> --- a/proxmox-auto-install-assistant/src/main.rs
> +++ b/proxmox-auto-install-assistant/src/main.rs
> @@ -4,12 +4,13 @@
>
> #![forbid(unsafe_code)]
>
> -use anyhow::{Context, Result, bail, format_err};
> +use anyhow::{Context, Result, anyhow, bail, format_err};
> use glob::Pattern;
> use proxmox_sys::{crypt::verify_crypt_pw, linux::tty::read_password};
> use std::{
> collections::BTreeMap,
> - fmt, fs,
> + fmt,
> + fs::{self, File},
> io::{self, IsTerminal, Read},
> path::{Path, PathBuf},
> process::{self, Command, Stdio},
> @@ -26,7 +27,9 @@ use proxmox_auto_installer::{
> verify_locale_settings, verify_network_settings,
> },
> };
> -use proxmox_installer_common::{FIRST_BOOT_EXEC_MAX_SIZE, FIRST_BOOT_EXEC_NAME, cli};
> +use proxmox_installer_common::{
> + FIRST_BOOT_EXEC_MAX_SIZE, FIRST_BOOT_EXEC_NAME, cli, setup::ProxmoxProduct,
> +};
>
> static PROXMOX_ISO_FLAG: &str = "/auto-installer-capable";
>
> @@ -34,6 +37,13 @@ static PROXMOX_ISO_FLAG: &str = "/auto-installer-capable";
> /// [LocaleInfo](`proxmox_installer_common::setup::LocaleInfo`) struct.
> const LOCALE_INFO: &str = include_str!("../../locale-info.json");
>
> +#[derive(Debug, PartialEq)]
> +struct CdInfo {
> + product: ProxmoxProduct,
> + release: String,
> + isorelease: String,
> +}
> +
> /// Arguments for the `device-info` command.
> struct CommandDeviceInfoArgs {
> /// Device type for which information should be shown.
> @@ -197,6 +207,25 @@ OPTIONS:
> }
> }
>
> +#[derive(Copy, Clone, Default)]
> +enum PxeLoader {
> + #[default]
> + None,
> + Ipxe,
> +}
> +
> +impl FromStr for PxeLoader {
> + type Err = anyhow::Error;
> +
> + fn from_str(s: &str) -> Result<Self> {
> + match s {
> + "none" => Ok(PxeLoader::None),
> + "ipxe" => Ok(PxeLoader::Ipxe),
> + _ => bail!("unknown PXE loader '{s}'"),
> + }
> + }
> +}
> +
> /// Arguments for the `prepare-iso` command.
> struct CommandPrepareISOArgs {
> /// Path to the source ISO to prepare.
> @@ -204,6 +233,7 @@ struct CommandPrepareISOArgs {
>
> /// Path to store the final ISO to, defaults to an auto-generated file name depending on mode
> /// and the same directory as the source file is located in.
> + /// If '--pxe' is specified, the path must be a directory.
> output: Option<PathBuf>,
>
> /// Where the automatic installer should fetch the answer file from.
> @@ -234,10 +264,20 @@ struct CommandPrepareISOArgs {
> ///
> /// Must be appropriately enabled in the answer file.
> on_first_boot: Option<PathBuf>,
> +
> + /// Instead of producing an ISO file, generate a 'initrd.img' and 'vmlinuz' file for use with
> + /// (i)PXE servers. The '--output' option must point to a directory to place these files in.
> + pxe_loader: Option<PxeLoader>,
> }
>
> impl cli::Subcommand for CommandPrepareISOArgs {
> fn parse(args: &mut cli::Arguments) -> Result<Self> {
> + let pxe_loader = match args.opt_value_from_str("--pxe") {
> + Ok(val) => val,
> + Err(cli::Error::OptionWithoutAValue(_)) => Some(PxeLoader::default()),
> + Err(err) => Err(err)?,
> + };
> +
> Ok(Self {
> output: args.opt_value_from_str("--output")?,
> fetch_from: args.value_from_str("--fetch-from")?,
> @@ -249,7 +289,7 @@ impl cli::Subcommand for CommandPrepareISOArgs {
> .opt_value_from_str("--partition-label")?
> .unwrap_or_else(default_partition_label),
> on_first_boot: args.opt_value_from_str("--on-first-boot")?,
> - // Needs to be last
> + pxe_loader,
> input: args.free_from_str()?,
> })
> }
> @@ -291,6 +331,7 @@ OPTIONS:
> --output <OUTPUT>
> Path to store the final ISO to, defaults to an auto-generated file name depending on mode
> and the same directory as the source file is located in.
> + If '--pxe' is specified, the path must be a directory.
>
> --fetch-from <FETCH_FROM>
> Where the automatic installer should fetch the answer file from.
> @@ -323,6 +364,15 @@ OPTIONS:
>
> Must be appropriately enabled in the answer file.
>
> + --pxe [<TYPE>]
> + Instead of producing an ISO file, generate a 'initrd.img' and 'vmlinuz' file for use with
> + (i)PXE servers. The '--output' option must point to a directory to place these files in.
> +
> + <TYPE> is optional. Possible value are 'syslinux', 'ipxe'. If <TYPE> is specified, a
> + configuration file is produced for the specified loader.
> +
> + [default: off]
> +
> -h, --help Print this help
> -V, --version Print version
> "#,
> @@ -639,12 +689,19 @@ fn prepare_iso(args: &CommandPrepareISOArgs) -> Result<()> {
> }
> }
>
> + if args.pxe_loader.is_some()
> + && let Some(out) = &args.output
> + && (!fs::exists(out)? || !fs::metadata(out)?.is_dir())
> + {
> + bail!("'--output' must point to a directory when '--pxe' is specified.");
> + }
> +
> if let Some(file) = &args.answer_file {
> println!("Checking provided answer file...");
> parse_answer(file)?;
> }
>
> - let iso_target = final_iso_location(args);
> + let iso_target = final_iso_location(args)?;
> let iso_target_file_name = match iso_target.file_name() {
> None => bail!("no base filename in target ISO path found"),
> Some(source_file_name) => source_file_name.to_string_lossy(),
> @@ -653,11 +710,14 @@ fn prepare_iso(args: &CommandPrepareISOArgs) -> Result<()> {
> let mut tmp_base = PathBuf::new();
> match args.tmp.as_ref() {
> Some(tmp_dir) => tmp_base.push(tmp_dir),
> + None if args.pxe_loader.is_some() && args.output.is_some() => {
> + tmp_base.push(args.output.as_ref().unwrap())
> + }
> None => tmp_base.push(iso_target.parent().unwrap()),
> }
>
> let mut tmp_iso = tmp_base.clone();
> - tmp_iso.push(format!("{iso_target_file_name}.tmp",));
> + tmp_iso.push(format!("{iso_target_file_name}.tmp"));
>
> println!("Copying source ISO to temporary location...");
> fs::copy(&args.input, &tmp_iso)?;
> @@ -681,6 +741,7 @@ fn prepare_iso(args: &CommandPrepareISOArgs) -> Result<()> {
> "/auto-installer-mode.toml",
> &uuid,
> )?;
> + let _ = fs::remove_file(&instmode_file_tmp);
>
> if let Some(answer_file) = &args.answer_file {
> inject_file_to_iso(&tmp_iso, answer_file, "/answer.toml", &uuid)?;
> @@ -695,38 +756,161 @@ fn prepare_iso(args: &CommandPrepareISOArgs) -> Result<()> {
> )?;
> }
>
> - println!("Moving prepared ISO to target location...");
> - fs::rename(&tmp_iso, &iso_target)?;
> - println!("Final ISO is available at {iso_target:?}.");
> + if args.pxe_loader.is_some() {
> + prepare_pxe_compatible_files(args, &tmp_base, &tmp_iso, &iso_target, &uuid)?;
> + let _ = fs::remove_file(tmp_iso);
> + } else {
> + println!("Moving prepared ISO to target location...");
> + fs::rename(&tmp_iso, &iso_target)?;
> + println!("Final ISO is available at {}.", iso_target.display());
> + }
>
> Ok(())
> }
>
> -fn final_iso_location(args: &CommandPrepareISOArgs) -> PathBuf {
> - if let Some(specified) = args.output.clone() {
> - return specified;
> +/// Creates and prepares all files needing for PXE-booting the installer.
> +///
> +/// The general flow here is:
> +/// 1. Extract the kernel and initrd image to the given target folder
> +/// 2. Recompress the initrd image from zstd to gzip, as PXE loaders generally
> +/// only support gzip.
> +/// 3. Remove the `/boot` directory from the target ISO, to save nearly 100 MiB
> +/// 4. If a particular (supported) PXE loader was given on the command line,
> +/// generate a configuration file for it.
> +///
> +/// # Arguments
> +///
> +/// `args` - Original commands given to the `prepare-iso` subcommand
> +/// `tmp_base` - Directory to use a scratch pad
> +/// `iso_source` - Source ISO file to extract kernel and initrd from
> +/// `iso_target` - Target ISO file to create, must be different from 'iso_source'
> +/// * `iso_uuid` - UUID to set for the target ISO
> +fn prepare_pxe_compatible_files(
> + args: &CommandPrepareISOArgs,
> + tmp_base: &Path,
> + iso_source: &Path,
> + iso_target: &Path,
> + iso_uuid: &str,
> +) -> Result<()> {
> + debug_assert_ne!(
> + iso_source, iso_target,
> + "source and target ISO files must be different"
> + );
> +
> + println!("Creating vmlinuz and initrd.img for PXE booting...");
> +
> + let out_dir = match &args.output {
> + Some(out) => out,
> + None => &args
> + .input
> + .parent()
> + .ok_or_else(|| anyhow!("valid parent path"))?
> + .to_path_buf(),
> + };
> +
> + let cd_info_path = out_dir.join(".cd-info.tmp");
> + extract_file_from_iso(iso_source, Path::new("/.cd-info"), &cd_info_path)?;
> + let cd_info = parse_cd_info(&fs::read_to_string(&cd_info_path)?)?;
> +
> + extract_file_from_iso(
> + iso_source,
> + Path::new("/boot/linux26"),
> + &out_dir.join("vmlinuz"),
> + )?;
> +
> + let compressed_initrd = tmp_base.join("initrd.img.zst");
> + extract_file_from_iso(
> + iso_source,
> + Path::new("/boot/initrd.img"),
> + &compressed_initrd,
> + )?;
> +
> + // re-compress the initrd from zstd to gzip, as iPXE does not support
> + // zstd-compressed initrds
> + {
> + println!("Recompressing initrd using gzip...");
> +
> + let input = File::open(&compressed_initrd)
> + .with_context(|| format!("opening {compressed_initrd:?}"))?;
> +
> + let output_file = File::create(out_dir.join("initrd.img"))
> + .with_context(|| format!("opening {out_dir:?}/initrd.img"))?;
> + let mut output = flate2::write::GzEncoder::new(output_file, flate2::Compression::default());
> +
> + zstd::stream::copy_decode(input, &mut output)?;
> + output.finish()?;
> }
> - let mut suffix: String = match args.fetch_from {
> - FetchAnswerFrom::Http => "auto-from-http",
> - FetchAnswerFrom::Iso => "auto-from-iso",
> - FetchAnswerFrom::Partition => "auto-from-partition",
> +
> + let iso_target_file_name = iso_target
> + .file_name()
> + .map(|name| name.to_string_lossy())
> + .ok_or_else(|| anyhow!("no filename found for ISO target?"))?;
> + println!("Creating ISO file {:?}...", iso_target_file_name);
> +
> + // need to remove the output file if it exists, as xorriso refuses to overwrite it
> + if fs::exists(iso_target)? {
> + fs::remove_file(iso_target).context("failed to remove existing target ISO file")?;
> + }
> +
> + // remove the whole /boot folder from the ISO to save some space (nearly 100 MiB), as it is
> + // unnecessary with PXE
> + remove_file_from_iso(iso_source, iso_target, iso_uuid, "/boot")?;
> +
> + let loader = args.pxe_loader.unwrap_or_default();
> + create_pxe_config_file(&loader, &cd_info, &iso_target_file_name, out_dir)?;
> +
> + // try to clean up all temporary files
> + let _ = fs::remove_file(&cd_info_path);
> + let _ = fs::remove_file(&compressed_initrd);
> + println!("PXE-compatible files are available in {out_dir:?}.");
> +
> + Ok(())
> +}
> +
> +fn final_iso_location(args: &CommandPrepareISOArgs) -> Result<PathBuf> {
> + if let Some(specified) = args.output.clone()
> + && args.pxe_loader.is_none()
> + {
> + return Ok(specified);
> + }
> +
> + let mut filename = args
> + .input
> + .file_stem()
> + .and_then(|s| s.to_str())
> + .ok_or_else(|| anyhow!("input name has no filename?"))?
> + .to_owned();
> +
> + match args.fetch_from {
> + FetchAnswerFrom::Http => filename.push_str("-auto-from-http"),
> + FetchAnswerFrom::Iso => filename.push_str("-auto-from-iso"),
> + FetchAnswerFrom::Partition => filename.push_str("-auto-from-partition"),
> }
> - .into();
>
> if args.url.is_some() {
> - suffix.push_str("-url");
> + filename.push_str("-url");
> }
> if args.cert_fingerprint.is_some() {
> - suffix.push_str("-fp");
> + filename.push_str("-fp");
> }
>
> - let base = args.input.parent().unwrap();
> - let iso = args.input.file_stem().unwrap();
> + filename.push_str(".iso");
>
> - let mut target = base.to_path_buf();
> - target.push(format!("{}-{}.iso", iso.to_str().unwrap(), suffix));
> -
> - target.to_path_buf()
> + if args.pxe_loader.is_some() {
> + let path = args
> + .output
> + .as_ref()
> + .ok_or_else(|| anyhow!("output directory must be specified in PXE mode"))?
> + .join(filename);
> + Ok(path)
> + } else {
> + let path = args
> + .input
> + .parent()
> + .ok_or_else(|| anyhow!("parent directory of input not found"))?
> + .join(filename);
> + Ok(path)
> + }
> }
>
> fn inject_file_to_iso(
> @@ -757,6 +941,212 @@ fn inject_file_to_iso(
> Ok(())
> }
>
> +/// Extracts a file from the given ISO9660 file.
> +///
> +/// If `file` points a directory inside the ISO, `outpath` must be a directory too.
> +///
> +/// # Arguments
> +///
> +/// * `iso` - Source ISO file to extract from
> +/// * `file` - Absolute file path inside the ISO file to extract.
> +/// * `outpath` - Output path to write the extracted file to.
> +fn extract_file_from_iso(iso: &Path, file: &Path, outpath: &Path) -> Result<()> {
> + debug_assert!(fs::exists(iso).unwrap_or_default());
> +
> + let result = Command::new("xorriso")
> + .arg("-osirrox")
> + .arg("on")
> + .arg("-indev")
> + .arg(iso)
> + .arg("-extract")
> + .arg(file)
> + .arg(outpath)
> + .output()?;
> +
> + if !result.status.success() {
> + bail!(
> + "Error extracting {file:?} from {iso:?} to {outpath:?}: {}",
> + String::from_utf8_lossy(&result.stderr)
> + );
> + }
> + Ok(())
> +}
> +
> +/// Removes a file from the ISO9660 file.
> +///
> +/// # Arguments
> +///
> +/// * `iso_in` - Source ISO file to remove the file from.
> +/// * `iso_out` - Target ISO file to create with the given filepath removed, must be different from
> +/// 'iso_in'
> +/// * `iso_uuid` - UUID to set for the target ISO.
> +/// * `path` - File path to remove from the ISO file.
> +fn remove_file_from_iso(
> + iso_in: &Path,
> + iso_out: &Path,
> + iso_uuid: &str,
> + path: impl AsRef<Path> + fmt::Debug,
> +) -> Result<()> {
> + debug_assert_ne!(
> + iso_in, iso_out,
> + "source and target ISO files must be different"
> + );
> +
> + let result = Command::new("xorriso")
> + .arg("-boot_image")
> + .arg("any")
> + .arg("keep")
> + .arg("-volume_date")
> + .arg("uuid")
> + .arg(iso_uuid)
> + .arg("-indev")
> + .arg(iso_in)
> + .arg("-outdev")
> + .arg(iso_out)
> + .arg("-rm_r")
> + .arg(path.as_ref())
> + .output()?;
> +
> + if !result.status.success() {
> + bail!(
> + "Error removing {path:?} from {iso_in:?}: {}",
> + String::from_utf8_lossy(&result.stderr)
> + );
> + }
> + Ok(())
> +}
> +
> +struct PxeBootOption {
> + /// Unique, short, single-word identifier among all entries
> + id: &'static str,
> + /// What to show in parenthesis in the menu select
> + description: &'static str,
> + /// Extra parameters to append to the kernel commandline
> + extra_params: &'static str,
> +}
> +
> +const DEFAULT_KERNEL_PARAMS: &str = "ramdisk_size=16777216 rw quiet";
> +const PXE_BOOT_OPTIONS: &[PxeBootOption] = &[
> + PxeBootOption {
> + id: "auto",
> + description: "Automated",
> + extra_params: "splash=silent proxmox-start-auto-installer",
> + },
> + PxeBootOption {
> + id: "gui",
> + description: "Graphical",
> + extra_params: "splash=silent",
> + },
> + PxeBootOption {
> + id: "tui",
> + description: "Terminal UI",
> + extra_params: "splash=silent proxmox-tui-mode vga=788",
> + },
> + PxeBootOption {
> + id: "serial",
> + description: "Terminal UI, Serial Console",
> + extra_params: "splash=silent proxmox-tui-mode console=ttyS0,115200",
> + },
> + PxeBootOption {
> + id: "debug",
> + description: "Debug Mode",
> + extra_params: "splash=verbose proxmox-debug vga=788",
> + },
> + PxeBootOption {
> + id: "debugtui",
> + description: "Terminal UI, Debug Mode",
> + extra_params: "splash=verbose proxmox-debug proxmox-tui-mode vga=788",
> + },
> + PxeBootOption {
> + id: "serialdebug",
> + description: "Serial Console, Debug Mode",
> + extra_params: "splash=verbose proxmox-debug proxmox-tui-mode console=ttyS0,115200",
> + },
> +];
> +
> +/// Creates a configuration file for the given PXE bootloader.
> +///
> +/// # Arguments
> +///
> +/// * `loader` - PXE bootloader to generate the configuration for
> +/// * `cd_info` - Information loaded from the ISO
> +/// * `iso_filename` - Final name of the ISO file, written to the PXE configuration
> +/// * `out_dir` - Output path to write the file(s) to
> +fn create_pxe_config_file(
> + loader: &PxeLoader,
> + cd_info: &CdInfo,
> + iso_filename: &str,
> + out_dir: &Path,
> +) -> Result<()> {
> + debug_assert!(fs::exists(out_dir).unwrap_or_default());
> +
> + let product_name = cd_info.product.full_name();
> +
> + let (filename, contents) = match loader {
> + PxeLoader::None => return Ok(()),
> + PxeLoader::Ipxe => {
> + let default_kernel =
> + format!("kernel vmlinuz {DEFAULT_KERNEL_PARAMS} initrd=initrd.img");
> +
> + let menu_items = PXE_BOOT_OPTIONS
> + .iter()
> + .map(|opt| {
> + format!(
> + "item {} Install {product_name} ({})\n",
> + opt.id, opt.description
> + )
> + })
> + .collect::<String>();
> +
> + let menu_options = PXE_BOOT_OPTIONS
> + .iter()
> + .map(|opt| {
> + format!(
> + r#":{}
> + echo Loading {product_name} {} Installer ...
> + {default_kernel} {}
> + goto load
> +
> +"#,
> + opt.id, opt.description, opt.extra_params
> + )
> + })
> + .collect::<String>();
> +
> + let script = format!(
> + r#"#!ipxe
> +
> +dhcp
> +
> +menu Welcome to {product_name} {}-{}
> +{menu_items}
> +choose --default auto --timeout 10000 target && goto ${{target}}
> +
> +{menu_options}
> +:load
> +initrd initrd.img
> +initrd {iso_filename} proxmox.iso
> +boot
> +"#,
> + cd_info.release, cd_info.isorelease
> + );
> +
> + println!("Creating boot.ipxe for iPXE booting...");
> + ("boot.ipxe", script)
> + }
> + };
> +
> + let target_path = out_dir.join(filename);
> + fs::create_dir_all(
> + target_path
> + .parent()
> + .ok_or_else(|| anyhow!("expected parent path"))?,
> + )?;
> +
> + fs::write(target_path, contents)?;
> + Ok(())
> +}
> +
> fn get_iso_uuid(iso: impl AsRef<Path>) -> Result<String> {
> let result = Command::new("xorriso")
> .arg("-dev")
> @@ -947,3 +1337,52 @@ fn check_prepare_requirements(args: &CommandPrepareISOArgs) -> Result<()> {
>
> Ok(())
> }
> +
> +fn parse_cd_info(raw_cd_info: &str) -> Result<CdInfo> {
> + let mut info = CdInfo {
> + product: ProxmoxProduct::PVE,
> + release: String::new(),
> + isorelease: String::new(),
> + };
> +
> + for line in raw_cd_info.lines() {
> + match line.split_once('=') {
> + Some(("PRODUCT", val)) => info.product = val.trim_matches('\'').parse()?,
> + Some(("RELEASE", val)) => info.release = val.trim_matches('\'').to_owned(),
> + Some(("ISORELEASE", val)) => info.isorelease = val.trim_matches('\'').to_owned(),
> + Some(_) => {}
> + None if line.is_empty() => {}
> + _ => bail!("invalid cd-info line: {line}"),
> + }
> + }
> +
> + Ok(info)
> +}
> +
> +#[cfg(test)]
> +mod tests {
> + use super::{CdInfo, parse_cd_info};
> + use anyhow::Result;
> + use proxmox_installer_common::setup::ProxmoxProduct;
> +
> + #[test]
> + fn parse_cdinfo() -> Result<()> {
> + let s = r#"
> +PRODUCT='pve'
> +PRODUCTLONG='Proxmox VE'
> +RELEASE='42.1'
> +ISORELEASE='1'
> +ISONAME='proxmox-ve'
> +"#;
> +
> + assert_eq!(
> + parse_cd_info(s)?,
> + CdInfo {
> + product: ProxmoxProduct::PVE,
> + release: "42.1".into(),
> + isorelease: "1".into(),
> + }
> + );
> + Ok(())
> + }
> +}
> diff --git a/proxmox-installer-common/src/cli.rs b/proxmox-installer-common/src/cli.rs
> index e2b4d81..5c7a4e6 100644
> --- a/proxmox-installer-common/src/cli.rs
> +++ b/proxmox-installer-common/src/cli.rs
> @@ -5,7 +5,7 @@ use std::process;
>
> use anyhow::Result;
>
> -pub use pico_args::Arguments;
> +pub use pico_args::{Arguments, Error};
>
> pub trait Subcommand {
> /// Parses the arguments for this command from an [`pico_args::Arguments`].
> diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs
> index 35949f0..8b1cbfe 100644
> --- a/proxmox-installer-common/src/setup.rs
> +++ b/proxmox-installer-common/src/setup.rs
> @@ -39,6 +39,15 @@ impl ProxmoxProduct {
> Self::PDM => "pdm",
> }
> }
> +
> + pub fn full_name(&self) -> &'static str {
> + match self {
> + Self::PVE => "Proxmox Virtual Environment",
> + Self::PMG => "Proxmox Mail Gateway",
> + Self::PBS => "Proxmox Backup Server",
> + Self::PDM => "Proxmox Datacenter Manager",
> + }
> + }
> }
>
> impl fmt::Display for ProxmoxProduct {
> @@ -52,6 +61,8 @@ impl fmt::Display for ProxmoxProduct {
> }
> }
>
> +serde_plain::derive_fromstr_from_deserialize!(ProxmoxProduct);
> +
> #[derive(Debug, Clone, Deserialize, Serialize)]
> pub struct ProductConfig {
> pub fullname: String,