[Concept,09/10] scripts: Add a script to modify an Ubuntu ISO for BLS

Message ID 20260418004014.1889749-10-sjg@u-boot.org
State New
Headers
Series efi-x86: boot Ubuntu live ISOs via U-Boot + BLS |

Commit Message

Simon Glass April 18, 2026, 12:40 a.m. UTC
  From: Simon Glass <sjg@chromium.org>

Rewrite an Ubuntu live ISO so that its appended EFI system partition
boots U-Boot instead of shim/grub. The script extracts the casper kernel
and initrd from the input ISO, builds a fresh FAT32 ESP with
u-boot-app.efi, a matching /loader/entry.conf and the kernel and initrd
copied across, then uses xorriso to stream the original ISO to a new one
with the ESP replaced in place. Every other boot record (BIOS El Torito,
grub2 MBR, GPT layout) is preserved verbatim, and the ISO9660 tree is
not touched, so casper still finds its squashfs by disc label at
runtime.

Intended to be used with the efi-x86_app64 target, which now enables
CONFIG_BOOTMETH_BLS=y, CONFIG_FS_ISOFS=y and CONFIG_JOLIET=y by default.

Signed-off-by: Simon Glass <sjg@chromium.org>
---

 scripts/ubuntu-iso-to-uboot.py | 250 +++++++++++++++++++++++++++++++++
 1 file changed, 250 insertions(+)
 create mode 100755 scripts/ubuntu-iso-to-uboot.py
  

Patch

diff --git a/scripts/ubuntu-iso-to-uboot.py b/scripts/ubuntu-iso-to-uboot.py
new file mode 100755
index 00000000000..ec32ac7b0dd
--- /dev/null
+++ b/scripts/ubuntu-iso-to-uboot.py
@@ -0,0 +1,250 @@ 
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0+
+"""Turn an Ubuntu live ISO into one that boots via U-Boot + BLS.
+
+BLS (Boot Loader Specification) is a freedesktop.org standard for
+describing boot menu entries as small files under /loader/entries/ on
+the EFI system partition. Each entry names a kernel, initrd and command
+line; U-Boot's BOOTMETH_BLS scans the ESP and presents them in its boot
+menu, replacing the shim/grub chain that Ubuntu ships by default.
+
+The ISO's appended EFI System Partition is replaced with a fresh ESP
+containing:
+
+    /EFI/BOOT/BOOTX64.EFI   U-Boot EFI app (built with CONFIG_BOOTMETH_BLS=y)
+    /casper/vmlinuz         copied from the input ISO
+    /casper/initrd          copied from the input ISO
+    /loader/entry.conf      BLS Type #1 entry pointing at the above
+
+The ISO9660 tree is preserved unchanged, so casper still finds its squashfs
+by disc label at runtime. All other boot records (BIOS El Torito, grub2 MBR,
+GPT layout) are preserved by xorriso's -boot_image any replay.
+
+Quick start (run from the root of the U-Boot tree)::
+
+    # 1. Install host tools
+    sudo apt install xorriso mtools dosfstools qemu-system-x86 ovmf
+
+    # 2. Download an Ubuntu live ISO (desktop or server both work)
+    curl -LO https://releases.ubuntu.com/24.04.1/ubuntu-24.04.1-desktop-amd64.iso
+
+    # 3. Build U-Boot as an x86_64 EFI application. The defconfig enables
+    #    BOOTMETH_BLS, FS_ISOFS and JOLIET. If rustc is not installed,
+    #    also pass -d RUST_EXAMPLES -d EXAMPLES to scripts/config and
+    #    re-run olddefconfig before building.
+    make O=/tmp/b/efi-x86_app64 efi-x86_app64_defconfig
+    make O=/tmp/b/efi-x86_app64 -j$(nproc)
+    # produces /tmp/b/efi-x86_app64/u-boot-app.efi
+
+    # 4. Rewrite the ISO to boot via U-Boot
+    scripts/ubuntu-iso-to-uboot.py ubuntu-24.04.1-desktop-amd64.iso \\
+        -u /tmp/b/efi-x86_app64/u-boot-app.efi \\
+        -o ubuntu-uboot.iso
+
+    # 5. Try it under QEMU + OVMF
+    cp /usr/share/OVMF/OVMF_VARS_4M.fd /tmp/OVMF_VARS.fd
+    qemu-system-x86_64 -machine q35 -m 4096 -smp 2 \\
+        -drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd \\
+        -drive if=pflash,format=raw,file=/tmp/OVMF_VARS.fd \\
+        -drive if=virtio,file=ubuntu-uboot.iso,format=raw,readonly=on
+
+Assumptions:
+    - Ubuntu-style live ISO with casper/vmlinuz and casper/initrd.
+    - Input ISO has an appended GPT EFI System Partition
+      (-append_partition 2 in xorriso's report).
+    - U-Boot EFI app is built with CONFIG_BOOTMETH_BLS=y,
+      CONFIG_CMD_ZBOOT=y and FAT support (efi-x86_app64_defconfig is
+      the reference config).
+    - xorriso, mtools and dosfstools are installed on the host
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+
+# Add the tools directory to the path for u_boot_pylib
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools'))
+
+# pylint: disable=wrong-import-position,import-error
+from u_boot_pylib import command
+from u_boot_pylib import tout
+
+DEFAULT_CMDLINE = 'console=ttyS0,115200 console=tty0 --- quiet'
+REQUIRED_TOOLS = ('xorriso', 'mcopy', 'mmd', 'mkfs.vfat')
+MIB = 1024 * 1024
+
+
+def check_tools() -> None:
+    missing = [t for t in REQUIRED_TOOLS if not shutil.which(t)]
+    if missing:
+        tout.fatal(
+            f'missing tools: {" ".join(missing)}\n'
+            f'try: apt install xorriso mtools dosfstools'
+        )
+
+
+def parse_boot_report(iso: Path) -> tuple[str, str]:
+    """Return (volume_label, esp_partition_guid) from xorriso's mkisofs report.
+
+    Raises SystemExit if the ISO does not have an appended EFI System
+    Partition on slot 2
+    """
+    # xorriso prints the report on stdout; stderr carries progress/status.
+    report = command.output(
+        'xorriso', '-indev', str(iso), '-report_el_torito', 'as_mkisofs',
+    )
+
+    m_vol = re.search(r"^-V '([^']*)'", report, re.MULTILINE)
+    m_esp = re.search(
+        r'^-append_partition 2 ([0-9a-fA-F]+) ', report, re.MULTILINE,
+    )
+    if not m_vol:
+        tout.fatal('could not find volume label in xorriso report')
+    if not m_esp:
+        tout.fatal(
+            'could not find appended partition 2 in xorriso report '
+            '(is this an Ubuntu-style hybrid ISO?)'
+        )
+    return m_vol.group(1), m_esp.group(1)
+
+
+def extract_from_iso(iso: Path, src: str, dst: Path) -> None:
+    """Pull a single file out of the ISO9660 tree via xorriso -osirrox"""
+    dst.parent.mkdir(parents=True, exist_ok=True)
+    command.run(
+        'xorriso', '-osirrox', 'on', '-indev', str(iso),
+        '-extract', f'/{src.lstrip("/")}', str(dst),
+    )
+
+
+def build_esp(
+    esp_path: Path,
+    size_mib: int,
+    uboot_efi: Path,
+    vmlinuz: Path,
+    initrd: Path,
+    entry_conf: str,
+) -> None:
+    """Create a fresh FAT32 ESP at esp_path populated with BLS + kernel.
+
+    Args:
+        esp_path (Path): Output file to hold the new ESP image
+        size_mib (int): Size of the ESP in mebibytes
+        uboot_efi (Path): U-Boot EFI app to install as /EFI/BOOT/BOOTX64.EFI
+        vmlinuz (Path): Kernel image to install under /casper/vmlinuz
+        initrd (Path): Initrd image to install under /casper/initrd
+        entry_conf (str): Contents for /loader/entry.conf (BLS Type #1)
+    """
+    with esp_path.open('wb') as f:
+        f.truncate(size_mib * MIB)
+    command.output('mkfs.vfat', '-F32', '-n', 'ESP', str(esp_path))
+    command.run('mmd', '-i', str(esp_path),
+                '::EFI', '::EFI/BOOT', '::loader', '::casper')
+
+    def put(src: Path, dst: str) -> None:
+        command.run('mcopy', '-i', str(esp_path), str(src), f'::{dst}')
+
+    put(uboot_efi, 'EFI/BOOT/BOOTX64.EFI')
+    put(vmlinuz, 'casper/vmlinuz')
+    put(initrd, 'casper/initrd')
+
+    entry = Path(esp_path.parent) / 'entry.conf'
+    entry.write_text(entry_conf)
+    put(entry, 'loader/entry.conf')
+
+
+def auto_esp_size(files: list[Path], headroom_mib: int = 16) -> int:
+    """Sum file sizes + headroom, round up to MiB, floor at 64 MiB (FAT32)"""
+    total = sum(f.stat().st_size for f in files) + headroom_mib * MIB
+    mib = (total + MIB - 1) // MIB
+    return max(mib, 64)
+
+
+def repack_iso(
+    in_iso: Path, out_iso: Path, esp_img: Path, esp_guid: str,
+) -> None:
+    """Stream the input ISO to a new ISO, replacing partition 2's data.
+
+    -boot_image any replay preserves every other boot record (BIOS El Torito,
+    grub2 MBR, GPT layout); only the bytes behind partition 2 are rewritten
+    """
+    command.run(
+        'xorriso',
+        '-indev', str(in_iso),
+        '-outdev', str(out_iso),
+        '-boot_image', 'any', 'replay',
+        '-append_partition', '2', esp_guid, str(esp_img),
+        '-commit',
+    )
+
+
+def main() -> None:
+    p = argparse.ArgumentParser(
+        description='Rewrite an Ubuntu live ISO to boot via U-Boot + BLS.',
+    )
+    p.add_argument('iso', type=Path, help='input Ubuntu live ISO')
+    p.add_argument('-u', '--uboot', type=Path, required=True,
+                   help='U-Boot EFI app (e.g. u-boot-app.efi)')
+    p.add_argument('-o', '--out', type=Path, required=True,
+                   help='output ISO path')
+    p.add_argument('-k', '--kernel', default='casper/vmlinuz',
+                   help='kernel path inside the input ISO')
+    p.add_argument('-i', '--initrd', default='casper/initrd',
+                   help='initrd path inside the input ISO')
+    p.add_argument('-a', '--cmdline', default=DEFAULT_CMDLINE,
+                   help='kernel command line written into loader/entry.conf')
+    p.add_argument('-t', '--title', default=None,
+                   help='BLS entry title (default: derived from volume label)')
+    p.add_argument('-s', '--esp-size', type=int, default=None,
+                   help='ESP size in MiB (default: auto-size to fit)')
+    args = p.parse_args()
+
+    if not args.iso.is_file():
+        tout.fatal(f'ISO not found: {args.iso}')
+    if not args.uboot.is_file():
+        tout.fatal(f'EFI app not found: {args.uboot}')
+    check_tools()
+
+    print(f'=> Reading boot config from {args.iso}')
+    # Extract the volume label and ESP partition GUID from xorriso's report
+    vol_id, esp_guid = parse_boot_report(args.iso)
+    title = args.title or f'U-Boot BLS boot ({vol_id})'
+    print(f'   Volume label: {vol_id}')
+    print(f'   ESP GUID:     {esp_guid}')
+
+    with tempfile.TemporaryDirectory(prefix='iso2uboot.') as td:
+        work = Path(td)
+        vmlinuz = work / 'vmlinuz'
+        initrd = work / 'initrd'
+
+        print(f'=> Extracting /{args.kernel} and /{args.initrd}')
+        extract_from_iso(args.iso, args.kernel, vmlinuz)
+        extract_from_iso(args.iso, args.initrd, initrd)
+
+        esp_mib = args.esp_size or auto_esp_size([vmlinuz, initrd, args.uboot])
+        print(f'=> Building {esp_mib} MiB ESP')
+
+        entry_conf = f'''\
+title {title}
+linux /casper/vmlinuz
+initrd /casper/initrd
+options {args.cmdline}
+'''
+        esp = work / 'esp.img'
+        build_esp(esp, esp_mib, args.uboot, vmlinuz, initrd, entry_conf)
+
+        print(f'=> Repacking to {args.out}')
+        repack_iso(args.iso, args.out, esp, esp_guid)
+
+    size_mib = args.out.stat().st_size / MIB
+    print(f'=> Done: {args.out} ({size_mib:.1f} MiB)')
+
+
+if __name__ == '__main__':
+    main()