public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files
@ 2026-02-04 12:09 Christoph Heiss
  0 siblings, 0 replies; only message in thread
From: Christoph Heiss @ 2026-02-04 12:09 UTC (permalink / raw)
  To: pve-devel

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,
-- 
2.52.0





^ permalink raw reply	[flat|nested] only message in thread

only message in thread, other threads:[~2026-02-04 12:10 UTC | newest]

Thread overview: (only message) (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-02-04 12:09 [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files Christoph Heiss

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal