* Re: [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files
2026-02-04 12:09 [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files Christoph Heiss
@ 2026-02-16 16:23 ` Hannes Duerr
2026-02-18 19:23 ` Alwin Antreich
` (3 subsequent siblings)
4 siblings, 0 replies; 8+ messages in thread
From: Hannes Duerr @ 2026-02-16 16:23 UTC (permalink / raw)
To: Christoph Heiss, pve-devel
Hi thanks for tackling this!
Tested the patch with tftp and tftp+http chain loading(which requires
small changes to the generated boot.ipxe), works smooth.
1. Noticed that when you don't pass any option to `--pxe` it always
assumes the next argument as input for the flag, even if the next
argument is clearly a flag it self.
This is not a big issue as long as there is not another flag with the
same behaviour which could be combined with `--pxe` but still a bit
annoying as you have to position the flag at the end of the command.
I assume this has to be fixed in our pico args handling and is not
really related to this patch. Still wanted to mention it here.
2. While testing i noticed that the VM which should be booted with ipxe
has to have atleast 6GB RAM, 4GB is not sufficient.
Already discussed this with @Chris and the assumption is that `ISO(1.7
GiB) + unpacked initramfs + unpacked installer live system` which has to
be loaded into RAM exceeds 4GB in size.
This limitation is not catastrophic, but it is certainly annoying,
especially because in environments where installation is fully
automated, the VMs can often be smaller (e.g., anything-a-a-S related
stuff).
We came up with the following two workarounds:
a) In addition to our ISO, offer another net-boot ISO that contains
almost no system packages, making it significantly smaller.
b) Remove the live installer part from the ISO when --pxe is used. This
would mean that you could then only install automatically and no longer
with TUI or GUI.
Both approaches definitely mean more effort.
Does anyone else have a good idea for a workaround?
Also one small nit inline.
On 2/4/26 1:10 PM, Christoph Heiss wrote:
[...]
> @@ -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
syslinux should be removed since it is not yet supported.
> + configuration file is produced for the specified loader.
> +
> + [default: off]
> +
> -h, --help Print this help
> -V, --version Print version
^ permalink raw reply [flat|nested] 8+ messages in thread* Re: [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files
2026-02-04 12:09 [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files 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
` (2 subsequent siblings)
4 siblings, 1 reply; 8+ messages in thread
From: Alwin Antreich @ 2026-02-18 19:23 UTC (permalink / raw)
To: Christoph Heiss, pve-devel
Hi Christoph,
just some general comments. :)
February 4, 2026 at 1:09 PM, "Christoph Heiss" <c.heiss@proxmox.com mailto:c.heiss@proxmox.com?to=%22Christoph%20Heiss%22%20%3Cc.heiss%40proxmox.com%3E > 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.
>
Frank and I have tested your patch and aside from what Hannes already wrote, it worked nicely. +1
I've tried additionally to PXE boot it with secure boot. It would be awesome if you could add a signed ipxe.efi.
Sadly I had to go with the shimx64.efi route (my test with the shim command didn't work :/ ).
I also love the idea of a netboot image that could bring down the memory usage and load time. Otherwise ipxe has sanboot, that might help on the ipxe front.
Thanks
Cheers,
Alwin
^ permalink raw reply [flat|nested] 8+ messages in thread* Re: [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files
2026-02-18 19:23 ` Alwin Antreich
@ 2026-02-25 15:07 ` Hannes Duerr
0 siblings, 0 replies; 8+ messages in thread
From: Hannes Duerr @ 2026-02-25 15:07 UTC (permalink / raw)
To: Alwin Antreich, Christoph Heiss, pve-devel
On 2/18/26 8:31 PM, Alwin Antreich wrote:
>> 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.
>>
> Frank and I have tested your patch and aside from what Hannes already wrote, it worked nicely. +1
>
> I've tried additionally to PXE boot it with secure boot. It would be awesome if you could add a signed ipxe.efi.
> Sadly I had to go with the shimx64.efi route (my test with the shim command didn't work :/ ).
We can't do the signing our self, but @f.gruenbichler told me yesterday
that there is finally secureboot support from ipxe upstream since last
november.
Further information and a link to download the signed ipxe-shim can be
found in the pull-request [0]
[0]
https://github.com/rhboot/shim-review/issues/319#issuecomment-3521239969
> I also love the idea of a netboot image that could bring down the memory usage and load time. Otherwise ipxe has sanboot, that might help on the ipxe front.
>
> Thanks
>
> Cheers,
> Alwin
^ permalink raw reply [flat|nested] 8+ messages in thread
* Re: [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files
2026-02-04 12:09 [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files Christoph Heiss
2026-02-16 16:23 ` Hannes Duerr
2026-02-18 19:23 ` Alwin Antreich
@ 2026-02-25 15:13 ` Hannes Duerr
2026-03-30 16:01 ` Thomas Lamprecht
2026-03-31 16:36 ` Christoph Heiss
4 siblings, 0 replies; 8+ messages in thread
From: Hannes Duerr @ 2026-02-25 15:13 UTC (permalink / raw)
To: Christoph Heiss, pve-devel
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,
^ permalink raw reply [flat|nested] 8+ messages in thread* Re: [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files
2026-02-04 12:09 [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files Christoph Heiss
` (2 preceding siblings ...)
2026-02-25 15:13 ` Hannes Duerr
@ 2026-03-30 16:01 ` Thomas Lamprecht
2026-03-31 10:31 ` Christoph Heiss
2026-03-31 16:36 ` Christoph Heiss
4 siblings, 1 reply; 8+ messages in thread
From: Thomas Lamprecht @ 2026-03-30 16:01 UTC (permalink / raw)
To: Christoph Heiss, pve-devel
Am 04.02.26 um 13:09 schrieb Christoph Heiss:
> 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.
Nice feature, some comments inline.
> diff --git a/proxmox-auto-install-assistant/src/main.rs b/proxmox-auto-install-assistant/src/main.rs
> [...]
> +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}'"),
> + }
> + }
> +}
> + --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.
The help text lists 'syslinux' as a possible value but FromStr doesn't
handle it, so passing --pxe syslinux gives a confusing "unknown PXE
loader 'syslinux'" error, let's drop that for now if it's currently not
planned.
Also, small grammar nit: "Possible value are" -> "Possible values are"
> + 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)?,
> + };
This has a subtle interaction with pico_args' optional value handling:
if --pxe directly precedes the positional input argument (e.g.
`prepare-iso --fetch-from http --pxe input.iso`), pico_args will try
to parse "input.iso" as a PxeLoader and fail with "unknown PXE loader
'input.iso'". Only works when --pxe is followed by another --flag or
is given an explicit type value.
Not a blocker since the same applies to other optional-value patterns
with pico_args, but might be worth a note in the help text (e.g.
"use '--pxe none' when no other flags follow"). Alternatively, use
--pxe as flag and --pxe-type <type> where the value is not optional.
> + 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.");
> + }
This check silently passes when --pxe is given without --output, since
the `let Some(out)` guard skips it. The actual error only surfaces later
inside final_iso_location() with a rather generic message. I'd add an
early dedicated check here, something like:
if args.pxe_loader.is_some() && args.output.is_none() {
bail!("'--output' must be specified when '--pxe' is used.");
}
> +fn prepare_pxe_compatible_files(
> + args: &CommandPrepareISOArgs,
> [...]
> + let out_dir = match &args.output {
> + Some(out) => out,
> + None => &args
> + .input
> + .parent()
> + .ok_or_else(|| anyhow!("valid parent path"))?
> + .to_path_buf(),
> + };
Two things here:
1) The None branch is dead code - prepare_pxe_compatible_files() is only
called when pxe_loader.is_some(), and final_iso_location() (called
earlier) already bails if output is None in PXE mode. If --output is
actually meant to be optional in PXE mode (falling back to the input
directory), then final_iso_location would need adjusting instead.
2) The error message `anyhow!("valid parent path")` reads as a sentence
fragment, not an error. Something like "input path has no parent
directory" would be clearer, but given (1) it's (currently) moot.
> diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs
> [...]
> + 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",
> + }
> + }
Minor: This is the third way to get a product display name (Display
gives the short lowercase form, ProductConfig::fullname the "Proxmox
VE"-style form, and now this one the full unabbreviated form). Fine for
now, but worth keeping in mind and not lets add yet another one in the
future ^^
Apart from the above, the overall approach looks good to me.
^ permalink raw reply [flat|nested] 8+ messages in thread* Re: [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files
2026-03-30 16:01 ` Thomas Lamprecht
@ 2026-03-31 10:31 ` Christoph Heiss
0 siblings, 0 replies; 8+ messages in thread
From: Christoph Heiss @ 2026-03-31 10:31 UTC (permalink / raw)
To: Thomas Lamprecht; +Cc: pve-devel
Thanks for the review!
On Mon Mar 30, 2026 at 6:01 PM CEST, Thomas Lamprecht wrote:
[..]
>> + --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.
>
> The help text lists 'syslinux' as a possible value but FromStr doesn't
> handle it, so passing --pxe syslinux gives a confusing "unknown PXE
> loader 'syslinux'" error, let's drop that for now if it's currently not
> planned.
Yeah, bit of a leftover that slipped through.
[..]
>> + 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)?,
>> + };
>
> This has a subtle interaction with pico_args' optional value handling:
> if --pxe directly precedes the positional input argument (e.g.
> `prepare-iso --fetch-from http --pxe input.iso`), pico_args will try
> to parse "input.iso" as a PxeLoader and fail with "unknown PXE loader
> 'input.iso'". Only works when --pxe is followed by another --flag or
> is given an explicit type value.
>
> Not a blocker since the same applies to other optional-value patterns
> with pico_args, but might be worth a note in the help text (e.g.
> "use '--pxe none' when no other flags follow"). Alternatively, use
> --pxe as flag and --pxe-type <type> where the value is not optional.
I'll switch it to the latter, with a separate option. Seems sane(r) than
trying to quash everything into one flag.
[..]
>> diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs
>> [...]
>> + 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",
>> + }
>> + }
>
> Minor: This is the third way to get a product display name (Display
> gives the short lowercase form, ProductConfig::fullname the "Proxmox
> VE"-style form, and now this one the full unabbreviated form). Fine for
> now, but worth keeping in mind and not lets add yet another one in the
> future ^^
Good point. Maybe using `PRODUCT_LONG` here from the cd-info would also
be fine too? It's the exact same string; apart from using "Proxmox VE"
instead of "Proxmox Virtual Environment".
If nothing speaks against that, I'd probably go that route instead,
otherwise I'll just keep it like it.
(_Really_ bikeshedding now, but it's the first thing users see, so my
thought here was to have it in exactly the same spelling as in the GRUB
menu of the ISO.)
^ permalink raw reply [flat|nested] 8+ messages in thread
* Re: [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files
2026-02-04 12:09 [PATCH installer] assistant: add support for splitting ISO into (i)PXE-compatible files Christoph Heiss
` (3 preceding siblings ...)
2026-03-30 16:01 ` Thomas Lamprecht
@ 2026-03-31 16:36 ` Christoph Heiss
4 siblings, 0 replies; 8+ messages in thread
From: Christoph Heiss @ 2026-03-31 16:36 UTC (permalink / raw)
To: pve-devel
v2 available: https://lore.proxmox.com/pve-devel/20260331163353.1955212-1-c.heiss@proxmox.com/
On Wed Feb 4, 2026 at 1:09 PM CET, 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,
^ permalink raw reply [flat|nested] 8+ messages in thread