public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Duerr <h.duerr@proxmox.com>
To: Christoph Heiss <c.heiss@proxmox.com>, pve-devel@lists.proxmox.com
Subject: Re: [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files
Date: Wed, 25 Feb 2026 16:13:56 +0100	[thread overview]
Message-ID: <aff736dc-b9f8-4836-95b3-4dc2301c425f@proxmox.com> (raw)
In-Reply-To: <20260204121025.630269-1-c.heiss@proxmox.com>

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,




      parent reply	other threads:[~2026-02-25 15:13 UTC|newest]

Thread overview: 5+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-02-04 12:09 Christoph Heiss
2026-02-16 16:23 ` Hannes Duerr
2026-02-18 19:23 ` Alwin Antreich
2026-02-25 15:07   ` Hannes Duerr
2026-02-25 15:13 ` Hannes Duerr [this message]

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=aff736dc-b9f8-4836-95b3-4dc2301c425f@proxmox.com \
    --to=h.duerr@proxmox.com \
    --cc=c.heiss@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
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