extensions: add uefi-iso — bootable live ISO from UEFI images (cloud/IPMI virtual CD)

Add the uefi-iso extension: it converts a UEFI image into a bootable live .iso
that boots from a BMC/IPMI virtual CD-ROM (cloud, IPMI, KVM). Booting from a
virtual CD means everything must live inside the ISO9660, like Ubuntu Server's
ISO: kernel + initrd + a squashfs of the rootfs, live-boot in the initrd, and a
self-contained GRUB (grub-mkstandalone, all modules embedded). Boot the live
system, then install to disk with the new armbian-config installer.

Details:
  - inject live-boot so the initrd mounts the squashfs with a RAM overlay (inert
    on a normal disk boot, so the .img still boots normally);
  - self-contained GRUB over a serial+VGA console with the Armbian wallpaper
    (font/image embedded, so no external-module failures); configurable timeout;
  - keep the pseudo-fs mount-point dirs in the squashfs, neutralize /etc/fstab
    and mask GRUB boot-record + Trixie systemd generators in the squashfs only
    (all restored afterwards) for a clean live boot;
  - arch-aware: amd64 (x86_64-efi/BOOTX64.EFI), arm64 (arm64-efi/BOOTAA64.EFI),
    riscv64 (experimental); warns and disables itself on non-UEFI boards;
  - drops the .img by default (the ISO carries the rootfs); host deps xorriso,
    squashfs-tools, mtools, dosfstools.

Requires the grub extension. ISO is UEFI-only and needs Secure Boot disabled
(GRUB/kernel unsigned).

Signed-off-by: Igor Pecovnik <igor@armbian.com>
This commit is contained in:
Igor Pecovnik
2026-08-03 15:34:50 +02:00
committed by Igor
parent e5cec7140c
commit 76b19c5b37
+275
View File
@@ -0,0 +1,275 @@
#
# SPDX-License-Identifier: GPL-2.0
#
# Extension: uefi-iso
#
# Produce a bootable LIVE .iso from a UEFI build (amd64 and arm64; riscv64 is
# EXPERIMENTAL/untested) so it can be booted from a BMC/IPMI *virtual CD* (cloud,
# IPMI, KVM). On a virtual CD the firmware boots via El Torito and the
# only device grub/kernel see is the ISO itself, so everything needed to boot
# must live inside the ISO9660 filesystem — exactly like Ubuntu Server's ISO:
#
# /live/vmlinuz - the kernel
# /live/initrd.img - the initrd, with live-boot support
# /live/filesystem.squashfs- the rootfs, mounted read-only + a RAM overlay
# El Torito UEFI boot - a self-contained grub (all modules embedded)
#
# An earlier "wrap the disk image" approach kept the kernel/rootfs on a separate
# GPT partition, which is invisible under virtual-CD boot — grub loaded but had
# nothing to boot. This live layout is the only shape a virtual CD actually runs.
#
# Enable with `enable_extension "uefi-iso"` on a UEFI target that uses the `grub`
# extension (uefi-x86 / uefi-arm64; riscv64 experimental).
#
# Firmware requirements to boot the ISO:
# * UEFI firmware (the ISO is UEFI-only, like stock Armbian UEFI images; it will
# NOT boot under legacy BIOS/SeaBIOS).
# * Secure Boot must be DISABLED: grub (grub-mkstandalone) and the Armbian kernel
# are unsigned, so a Secure-Boot-enabled machine rejects them. Signing the boot
# chain (Debian shim + an Armbian MOK, or distro-signed kernel) is a possible
# future addition; for now boot with Secure Boot off.
#
# Vars:
# SKIP_UEFI_ISO=yes - do nothing
# UEFI_ISO_VOLID="..." - ISO9660 volume id (max 32 chars, default ARMBIAN)
# UEFI_ISO_COMP="zstd" - squashfs compressor (zstd|xz|gzip)
# UEFI_ISO_TIMEOUT=1 - grub menu seconds (0 = boot immediately; >0 shows the splash)
# UEFI_ISO_KEEP_IMG=no - keep the .img alongside the .iso (default: drop it)
#
function extension_prepare_config__prepare_uefi_iso_config() {
declare -g SKIP_UEFI_ISO="${SKIP_UEFI_ISO:-no}"
[[ "${SKIP_UEFI_ISO}" == "yes" ]] && return 0
# uefi-iso only applies to the generic UEFI boards (uefi-x86 / uefi-arm64 /
# uefi-riscv64 / ...). On anything else, warn and disable rather than error.
if [[ "${BOARD}" != uefi* ]]; then
display_alert "Extension: ${EXTENSION}: disabled" "only supported on UEFI boards (BOARD=uefi*), not '${BOARD}'" "wrn"
declare -g SKIP_UEFI_ISO="yes"
return 0
fi
declare -g UEFI_ISO_VOLID="${UEFI_ISO_VOLID:-ARMBIAN}"
declare -g UEFI_ISO_COMP="${UEFI_ISO_COMP:-zstd}"
declare -g UEFI_ISO_TIMEOUT="${UEFI_ISO_TIMEOUT:-1}"
declare -g UEFI_ISO_KEEP_IMG="${UEFI_ISO_KEEP_IMG:-no}" # the ISO carries the rootfs; drop the .img by default
# The kernel/grub run over a serial-and-VGA console: gfxterm needs a font/module
# that is not embedded, and IPMI wants serial/text anyway.
if [[ "${UEFI_GRUB_TERMINAL:-gfxterm}" == *gfxterm* ]]; then
declare -g UEFI_GRUB_TERMINAL="serial console"
fi
# live-boot lets the initrd find /live/filesystem.squashfs on the boot medium
# and mount it with a writable RAM overlay. Inert on a normal disk boot (only
# activates with boot=live), so the .img still boots normally.
add_packages_to_image live-boot live-boot-initramfs-tools
}
#### *run before installing host dependencies*
function add_host_dependencies__uefi_iso_host_deps() {
[[ "${SKIP_UEFI_ISO}" == "yes" ]] && return 0
EXTRA_BUILD_DEPS+=("uefi-iso::xorriso" "uefi-iso::squashfs-tools" "uefi-iso::mtools" "uefi-iso::dosfstools")
}
#### *hack the mounted final image, before unmount*
# Runs while the final rootfs is mounted at ${MOUNT} (loop ${LOOP}) with the
# live-boot-enabled initrd already built — the right moment to assemble the ISO.
function pre_umount_final_image__800_build_uefi_iso() {
[[ "${SKIP_UEFI_ISO}" == "yes" ]] && return 0
# Map ARCH to the grub EFI target and the fallback boot filename the firmware
# looks for on removable media (BOOTX64.EFI on x86_64, BOOTAA64.EFI on aarch64).
declare grub_efi_format efi_boot_name
case "${ARCH}" in
amd64) grub_efi_format="x86_64-efi" && efi_boot_name="BOOTX64.EFI" ;;
arm64) grub_efi_format="arm64-efi" && efi_boot_name="BOOTAA64.EFI" ;;
riscv64) # experimental / untested
grub_efi_format="riscv64-efi" && efi_boot_name="BOOTRISCV64.EFI"
display_alert "Extension: ${EXTENSION}: riscv64 is EXPERIMENTAL and untested" "proceed at your own risk" "wrn"
;;
*)
display_alert "Extension: ${EXTENSION}: skipping" "uefi-iso targets amd64/arm64/riscv64 UEFI, ARCH=${ARCH}" "wrn"
return 0
;;
esac
[[ -z "${version}" ]] && exit_with_error "version is not set"
display_alert "Building live UEFI ISO" "${EXTENSION}" "info"
declare tmp_dir stage_dir
mkdir -p "${DESTIMG}"
tmp_dir="$(mktemp -d -p "${DESTIMG}" "uefi-iso.XXXXXX")"
stage_dir="${tmp_dir}/iso-root"
mkdir -p "${stage_dir}/live" "${stage_dir}/boot/grub"
# --- kernel + initrd (newest real files in the image /boot) ---
declare kernel_file initrd_file
kernel_file="$(ls -1 "${MOUNT}"/boot/vmlinuz-* 2>/dev/null | grep -vE '\.(manifest|dtb|old)$' | sort -V | tail -1)"
initrd_file="$(ls -1 "${MOUNT}"/boot/initrd.img-* 2>/dev/null | grep -vE '\.manifest$' | sort -V | tail -1)"
[[ -f "${kernel_file}" ]] || exit_with_error "uefi-iso: no kernel found under ${MOUNT}/boot"
[[ -f "${initrd_file}" ]] || exit_with_error "uefi-iso: no initrd found under ${MOUNT}/boot"
display_alert "Using kernel/initrd" "$(basename "${kernel_file}") / $(basename "${initrd_file}")" "info"
# run_host_command_logged re-parses its args through `bash -c`, so every call
# below is built as an array and passed as "${cmd[*]@Q}" (per-element shell
# quoting) so dynamic values (paths, user-set UEFI_ISO_VOLID) cannot re-parse.
declare -a cmd
cmd=(cp -v "${kernel_file}" "${stage_dir}/live/vmlinuz") && run_host_command_logged "${cmd[*]@Q}"
cmd=(cp -v "${initrd_file}" "${stage_dir}/live/initrd.img") && run_host_command_logged "${cmd[*]@Q}"
# --- rootfs squashfs ---
# The image /etc/fstab pins the installed root and /boot/efi by UUID; those
# devices do not exist when booted from CD, so systemd blocks ~90s waiting for
# them and the fstab-generator chokes on a duplicate '/' entry (live-boot
# already provides root). Neutralize fstab in the squashfs ONLY, then restore
# the real one so the .img still boots normally.
declare fstab_bak=""
if [[ -f "${MOUNT}/etc/fstab" ]]; then
fstab_bak="$(mktemp)"
cp -a "${MOUNT}/etc/fstab" "${fstab_bak}"
cat > "${MOUNT}/etc/fstab" <<- 'LIVEFSTAB'
# Live ISO: root is provided by live-boot (RAM overlay).
# The installed image's UUID entries are intentionally omitted.
LIVEFSTAB
fi
# Mask GRUB boot-success bookkeeping services in the live squashfs only: on a
# read-only live medium they have no writable grubenv to record into, so they
# fail every boot ("degraded" state, red [FAILED] lines). Removed after squashing
# so the installed .img (if kept) still records boot success normally.
declare -a live_mask=(grub-initrd-fallback.service grub2-common.service)
declare svc
for svc in "${live_mask[@]}"; do
ln -sfT /dev/null "${MOUNT}/etc/systemd/system/${svc}"
done
# Silence a live-irrelevant systemd generator that spams the console at first
# boot on Trixie+: systemd-ssh-generator fails querying AF_VSOCK on hosts with
# no vsock. Masked via /dev/null in the high-priority generator dir. (The other
# offender, gpt-auto, is disabled on the kernel cmdline: systemd.gpt_auto=0.)
declare -a live_gen_mask=(systemd-ssh-generator)
declare gen
mkdir -p "${MOUNT}/etc/systemd/system-generators"
for gen in "${live_gen_mask[@]}"; do
ln -sfT /dev/null "${MOUNT}/etc/systemd/system-generators/${gen}"
done
# Exclude the CONTENTS of pseudo/ephemeral dirs but KEEP the (empty) directories
# themselves: they are mount points the live rootfs needs. Excluding the
# directories outright leaves the live system with no /proc,/sys,/dev,/run, so
# init cannot mount them and panics ("Attempted to kill init").
display_alert "Creating rootfs squashfs (${UEFI_ISO_COMP})" "${EXTENSION}" "info"
cmd=(mksquashfs "${MOUNT}" "${stage_dir}/live/filesystem.squashfs"
-noappend -comp "${UEFI_ISO_COMP}" -no-progress -wildcards
-e "proc/*" "sys/*" "dev/*" "run/*" "tmp/*" "var/tmp/*" "var/cache/apt/archives/*")
# Capture the result instead of letting a failure abort here, so the mounted
# image state is ALWAYS restored below (a kept .img is never left with the
# live-only fstab stub / masked services). Fail after restoring.
declare squashfs_rc=0
run_host_command_logged "${cmd[*]@Q}" || squashfs_rc=$?
# restore the real fstab so the installed .img is unaffected
if [[ -n "${fstab_bak}" ]]; then
cp -a "${fstab_bak}" "${MOUNT}/etc/fstab"
rm -f "${fstab_bak}"
fi
# undo the live-only service/generator masks so the installed .img is unaffected
for svc in "${live_mask[@]}"; do
rm -f "${MOUNT}/etc/systemd/system/${svc}"
done
for gen in "${live_gen_mask[@]}"; do
rm -f "${MOUNT}/etc/systemd/system-generators/${gen}"
done
[[ "${squashfs_rc}" -ne 0 ]] && exit_with_error "uefi-iso: mksquashfs failed (exit ${squashfs_rc})"
# --- self-contained grub EFI (embeds our cfg + all modules; no external deps,
# so 'terminal gfxterm not found' and missing-module failures cannot happen) ---
# Show the Armbian grub wallpaper (embedded, so no external module/font lookup
# like the one that used to fail with 'terminal gfxterm not found'). gfxterm
# renders on the VGA/BMC-KVM screen only; serial keeps a text menu for SOL. If
# the font/graphics do not init, fall back to a plain text console.
# $prefix (grub's own var) always points at the embedded memdisk, even after the
# menuentry's `search` changes $root — escaped here so the shell leaves it alone.
cat > "${MOUNT}/tmp/uefi-iso-grub.cfg" <<- GRUBCFG
set default=0
set timeout=${UEFI_ISO_TIMEOUT}
insmod serial
serial --unit=0 --speed=115200
terminal_input console serial
if loadfont \$prefix/fonts/unicode.pf2 ; then
insmod all_video
insmod gfxterm
insmod png
set gfxmode=auto
terminal_output gfxterm serial
background_image \$prefix/armbian.png
else
terminal_output console serial
fi
menuentry "Armbian ${version} (live)" {
search --no-floppy --set=root --file /live/vmlinuz
linux /live/vmlinuz boot=live components loglevel=6 systemd.gpt_auto=0 console=ttyS0,115200 console=ttyAMA0,115200 console=tty1
initrd /live/initrd.img
}
GRUBCFG
display_alert "Building standalone grub EFI" "${EXTENSION}" "info"
# TMPDIR=/tmp: grub-mkstandalone's mkdtemp otherwise inherits the host TMPDIR,
# a path that does not exist inside the chroot ("cannot make temporary directory").
declare -a gm_args=(chroot "${MOUNT}" env TMPDIR=/tmp grub-mkstandalone
--format="${grub_efi_format}"
--output="/tmp/${efi_boot_name}"
--modules="part_gpt part_msdos fat iso9660 normal linux echo all_video gfxterm gfxterm_background png test true loadenv search search_fs_uuid search_fs_file search_label configfile serial terminal ls cat halt reboot"
"boot/grub/grub.cfg=/tmp/uefi-iso-grub.cfg")
# embed the font + wallpaper (paths are inside the chroot) if present
if [[ -f "${MOUNT}/usr/share/grub/unicode.pf2" && -f "${MOUNT}/usr/share/images/grub/wallpaper.png" ]]; then
gm_args+=("boot/grub/fonts/unicode.pf2=/usr/share/grub/unicode.pf2" "boot/grub/armbian.png=/usr/share/images/grub/wallpaper.png")
else
display_alert "Extension: ${EXTENSION}: grub wallpaper/font missing" "booting to text menu" "info"
fi
run_host_command_logged "${gm_args[*]@Q}"
[[ -f "${MOUNT}/tmp/${efi_boot_name}" ]] || exit_with_error "uefi-iso: grub-mkstandalone did not produce ${efi_boot_name}"
# --- El Torito EFI boot image: a small FAT holding /EFI/BOOT/${efi_boot_name} ---
declare efi_img="${stage_dir}/boot/grub/efiboot.img"
declare efi_bytes efi_blocks
efi_bytes="$(stat -c%s "${MOUNT}/tmp/${efi_boot_name}")"
efi_blocks=$(((efi_bytes / 1024) + 2048)) # grub + FAT overhead + slack, in KiB
cmd=(dd if=/dev/zero of="${efi_img}" bs=1024 count="${efi_blocks}" status=none) && run_host_command_logged "${cmd[*]@Q}"
cmd=(mkfs.vfat -n UEFI_ISO "${efi_img}") && run_host_command_logged "${cmd[*]@Q}"
cmd=(mmd -i "${efi_img}" ::/EFI ::/EFI/BOOT) && run_host_command_logged "${cmd[*]@Q}"
cmd=(mcopy -i "${efi_img}" "${MOUNT}/tmp/${efi_boot_name}" "::/EFI/BOOT/${efi_boot_name}") && run_host_command_logged "${cmd[*]@Q}"
rm -f "${MOUNT}/tmp/${efi_boot_name}" "${MOUNT}/tmp/uefi-iso-grub.cfg"
# --- assemble the hybrid ISO ---
declare iso="${DESTIMG}/${version}.iso"
display_alert "Assembling ISO" "${iso}" "info"
cmd=(xorriso -as mkisofs
-iso-level 3
-volid "${UEFI_ISO_VOLID:0:32}"
-full-iso9660-filenames
-e boot/grub/efiboot.img
-no-emul-boot
-isohybrid-gpt-basdat
-o "${iso}"
"${stage_dir}")
run_host_command_logged "${cmd[*]@Q}"
cmd=(rm -rf "${tmp_dir}") && run_host_command_logged "${cmd[*]@Q}"
# The ISO is picked up from ${DESTIMG} by the build's normal finalization
# (compress/checksum + move of all ${version}.* to ${FINALDEST}), same as the
# .img — no need to track its path in a global that would go stale after the move.
display_alert "Created live UEFI ISO" "$(basename "${iso}") ($(du -h "${iso}" | cut -f1))" "info"
return 0
}
#### *custom post build hook*
# The live ISO already contains the whole rootfs (as a squashfs), so the .img is
# redundant. Drop it (default) so only the .iso is finalized/published; runs late
# so any image-output-* conversion (qcow2, ...) has already consumed the .img.
function post_build_image__990_uefi_iso_drop_img() {
[[ "${SKIP_UEFI_ISO}" == "yes" ]] && return 0
[[ "${UEFI_ISO_KEEP_IMG}" == "yes" ]] && return 0
[[ -f "${DESTIMG}/${version}.iso" ]] || return 0 # only drop the .img if the ISO was produced
if [[ -f "${DESTIMG}/${version}.img" ]]; then
display_alert "Extension: ${EXTENSION}: dropping .img (ISO carries the rootfs)" "${version}.img" "info"
run_host_command_logged rm -vf "${DESTIMG}/${version}.img" "${DESTIMG}/${version}.img.txt"
fi
return 0
}