diff --git a/live-build/isobuilder/boot/__init__.py b/live-build/isobuilder/boot/__init__.py new file mode 100644 index 00000000..a43e5132 --- /dev/null +++ b/live-build/isobuilder/boot/__init__.py @@ -0,0 +1,43 @@ +"""Boot configuration package for ISO builder. + +This package contains architecture-specific boot configurators for building +bootable ISOs for different architectures. +""" + +import pathlib +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..apt_state import AptStateManager + from ..builder import Logger + from .base import BaseBootConfigurator + + +def make_boot_configurator_for_arch( + arch: str, + logger: "Logger", + apt_state: "AptStateManager", + iso_root: pathlib.Path, +) -> "BaseBootConfigurator": + """Factory function to create boot configurator for a specific architecture.""" + from .amd64 import AMD64BootConfigurator + from .arm64 import ARM64BootConfigurator + from .ppc64el import PPC64ELBootConfigurator + from .riscv64 import RISCV64BootConfigurator + from .s390x import S390XBootConfigurator + + if arch == "amd64": + return AMD64BootConfigurator(logger, apt_state, iso_root) + elif arch == "arm64": + return ARM64BootConfigurator(logger, apt_state, iso_root) + elif arch == "ppc64el": + return PPC64ELBootConfigurator(logger, apt_state, iso_root) + elif arch == "riscv64": + return RISCV64BootConfigurator(logger, apt_state, iso_root) + elif arch == "s390x": + return S390XBootConfigurator(logger, apt_state, iso_root) + else: + raise ValueError(f"Unsupported architecture: {arch}") + + +__all__ = ["make_boot_configurator_for_arch"] diff --git a/live-build/isobuilder/boot/amd64.py b/live-build/isobuilder/boot/amd64.py new file mode 100644 index 00000000..6773d0f8 --- /dev/null +++ b/live-build/isobuilder/boot/amd64.py @@ -0,0 +1,201 @@ +"""AMD64/x86_64 architecture boot configuration.""" + +import pathlib +import shutil + +from .uefi import UEFIBootConfigurator +from .base import default_kernel_params + + +class AMD64BootConfigurator(UEFIBootConfigurator): + """Boot setup for AMD64/x86_64 architecture.""" + + efi_suffix = "x64" + grub_target = "x86_64" + arch = "amd64" + + def mkisofs_opts(self) -> list[str | pathlib.Path]: + # Boring mkisofs options that should be set somewhere architecture independent. + opts: list[str | pathlib.Path] = ["-J", "-joliet-long", "-l"] + + # Generalities on booting + # + # There is a 2x2 matrix of boot modes we care about: legacy or UEFI + # boot modes and having the installer be on a cdrom or a disk. Booting + # from cdrom uses the el torito standard and booting from disk expects + # a MBR or GPT partition table. + # + # https://wiki.osdev.org/El-Torito has a lot more background on this. + + # ## Set up the mkisofs options for legacy boot. + + # Set the el torito boot image "name", i.e. the path on the ISO + # containing the bootloader for legacy-cdrom boot. + opts.extend(["-b", "boot/grub/i386-pc/eltorito.img"]) + + # Back in the day, el torito booting worked by emulating a floppy + # drive. This hasn't been a useful way of operating for a long time. + opts.append("-no-emul-boot") + + # Misc options to make the legacy-cdrom boot work. + opts.extend(["-boot-load-size", "4", "-boot-info-table", "--grub2-boot-info"]) + + # The bootloader to write to the MBR for legacy-disk boot. + # + # We use the grub stage1 bootloader, boot_hybrid.img, which then jumps + # to the eltorito image based on the information xorriso provides it + # via the --grub2-boot-info option. + opts.extend( + [ + "--grub2-mbr", + self.grub_dir.joinpath("usr/lib/grub/i386-pc/boot_hybrid.img"), + ] + ) + + # ## Set up the mkisofs options for UEFI boot. + opts.extend(self.get_uefi_mkisofs_opts()) + + # ## Add cd-boot-tree to the ISO + opts.append(str(self.boot_tree)) + + return opts + + def extract_files(self) -> None: + with self.logger.logged("extracting AMD64 boot files"): + + # Extract UEFI files (common with ARM64) + self.extract_uefi_files() + + # AMD64-specific: Add BIOS/legacy boot files + with self.logger.logged("adding BIOS/legacy boot files"): + self.download_and_extract_package("grub-pc-bin", self.grub_dir) + + grub_boot_dir = self.boot_tree.joinpath("boot", "grub", "i386-pc") + grub_boot_dir.mkdir(parents=True, exist_ok=True) + + src_grub_dir = self.grub_dir.joinpath("usr", "lib", "grub", "i386-pc") + + shutil.copy(src_grub_dir.joinpath("eltorito.img"), grub_boot_dir) + + self.copy_grub_modules( + src_grub_dir, grub_boot_dir, ["*.mod", "*.lst", "*.o"] + ) + + def generate_grub_config(self) -> None: + """Generate grub.cfg and loopback.cfg for the boot tree.""" + # Generate grub.cfg + kernel_params = default_kernel_params(self.project) + + boot_grub_dir = self.boot_tree.joinpath("boot", "grub") + boot_grub_dir.mkdir(parents=True, exist_ok=True) + + grub_cfg = boot_grub_dir.joinpath("grub.cfg") + + # Write common GRUB header + self.write_grub_header(grub_cfg) + + # Main menu entry + with grub_cfg.open("a") as f: + f.write( + f"""menuentry "Try or Install {self.humanproject}" {{ + set gfxpayload=keep + linux /casper/vmlinuz {kernel_params} + initrd /casper/initrd +}} +""" + ) + + # All but server get safe-graphics mode + if self.project != "ubuntu-server": + with grub_cfg.open("a") as f: + f.write( + f"""menuentry "{self.humanproject} (safe graphics)" {{ + set gfxpayload=keep + linux /casper/vmlinuz nomodeset {kernel_params} + initrd /casper/initrd +}} +""" + ) + + # ubiquity based projects get OEM mode + if "maybe-ubiquity" in kernel_params: + oem_kernel_params = kernel_params.replace( + "maybe-ubiquity", "only-ubiquity oem-config/enable=true" + ) + with grub_cfg.open("a") as f: + f.write( + f"""menuentry "OEM install (for manufacturers)" {{ + set gfxpayload=keep + linux /casper/vmlinuz {oem_kernel_params} + initrd /casper/initrd +}} +""" + ) + + # Calamares-based projects get OEM mode + if self.project in ["lubuntu", "kubuntu"]: + with grub_cfg.open("a") as f: + f.write( + f"""menuentry "OEM install (for manufacturers)" {{ + set gfxpayload=keep + linux /casper/vmlinuz {kernel_params} oem-config/enable=true + initrd /casper/initrd +}} +""" + ) + + # Currently only server is built with HWE, hence no safe-graphics/OEM + if self.hwe: + with grub_cfg.open("a") as f: + f.write( + f"""menuentry "{self.humanproject} with the HWE kernel" {{ + set gfxpayload=keep + linux /casper/hwe-vmlinuz {kernel_params} + initrd /casper/hwe-initrd +}} +""" + ) + + # Create the loopback config, based on the main config + with grub_cfg.open("r") as f: + content = f.read() + + # sed: delete from line 1 to menu_color_highlight, delete from + # grub_platform to end and replace '---' with + # 'iso-scan/filename=${iso_path} ---' in lines with 'linux' + lines = content.split("\n") + start_idx = 0 + for i, line in enumerate(lines): + if "menu_color_highlight" in line: + start_idx = i + 1 + break + + end_idx = len(lines) + for i, line in enumerate(lines): + if "grub_platform" in line: + end_idx = i + break + + loopback_lines = lines[start_idx:end_idx] + loopback_lines = [ + ( + line.replace("---", "iso-scan/filename=${iso_path} ---") + if "linux" in line + else line + ) + for line in loopback_lines + ] + + loopback_cfg = boot_grub_dir.joinpath("loopback.cfg") + with loopback_cfg.open("w") as f: + f.write("\n".join(loopback_lines)) + + # UEFI Entries (wrapped in grub_platform check for dual BIOS/UEFI support) + with grub_cfg.open("a") as f: + f.write("grub_platform\n") + f.write('if [ "$grub_platform" = "efi" ]; then\n') + + self.write_uefi_menu_entries(grub_cfg) + + with grub_cfg.open("a") as f: + f.write("fi\n") diff --git a/live-build/isobuilder/boot/arm64.py b/live-build/isobuilder/boot/arm64.py new file mode 100644 index 00000000..b1623e4d --- /dev/null +++ b/live-build/isobuilder/boot/arm64.py @@ -0,0 +1,81 @@ +"""ARM 64-bit architecture boot configuration.""" + +import pathlib + +from .uefi import UEFIBootConfigurator +from .base import default_kernel_params + + +class ARM64BootConfigurator(UEFIBootConfigurator): + """Boot setup for ARM 64-bit architecture.""" + + efi_suffix = "aa64" + grub_target = "arm64" + arch = "arm64" + + def mkisofs_opts(self) -> list[str | pathlib.Path]: + """Return mkisofs options for ARM64.""" + opts: list[str | pathlib.Path] = [ + "-J", + "-joliet-long", + "-l", + "-c", + "boot/boot.cat", + ] + # Add common UEFI options + opts.extend(self.get_uefi_mkisofs_opts()) + # ARM64-specific: partition cylinder alignment + opts.extend(["-partition_cyl_align", "all"]) + opts.append(self.boot_tree) + return opts + + def extract_files(self) -> None: + """Download and extract bootloader packages for ARM64.""" + with self.logger.logged("extracting ARM64 boot files"): + self.extract_uefi_files() + + def generate_grub_config(self) -> None: + """Generate grub.cfg for ARM64.""" + kernel_params = default_kernel_params(self.project) + + grub_cfg = self.grub_dir.joinpath("grub.cfg") + + # Write common GRUB header + self.write_grub_header(grub_cfg) + + # ARM64-specific: Snapdragon workarounds + with grub_cfg.open("a") as f: + f.write( + """set cmdline= +smbios --type 4 --get-string 5 --set proc_version +regexp "Snapdragon.*" "$proc_version" +if [ $? = 0 ]; then + # Work around Snapdragon X firmware bug. cutmem is not allowed in lockdown mode. + if [ $lockdown != "y" ]; then + cutmem 0x8800000000 0x8fffffffff + fi + # arm64.nopauth works around 8cx Gen 3 firmware bug + cmdline="clk_ignore_unused pd_ignore_unused arm64.nopauth" +fi + +menuentry "Try or Install {self.humanproject}" {{ +\tset gfxpayload=keep +\tlinux\t/casper/vmlinuz $cmdline {kernel_params} console=tty0 +\tinitrd\t/casper/initrd +}} +""" + ) + + # HWE kernel option if available + self.write_hwe_menu_entry( + grub_cfg, + "vmlinuz", + f"{kernel_params} console=tty0", + extra_params="$cmdline ", + ) + + # Note: ARM64 HWE also includes $dtb in the original shell script, + # but it's not actually set anywhere in the grub.cfg, so we omit it here + + # UEFI Entries (ARM64 is UEFI-only, no grub_platform check needed) + self.write_uefi_menu_entries(grub_cfg) diff --git a/live-build/isobuilder/boot/base.py b/live-build/isobuilder/boot/base.py new file mode 100644 index 00000000..8ca8b8f9 --- /dev/null +++ b/live-build/isobuilder/boot/base.py @@ -0,0 +1,111 @@ +"""Base classes and helper functions for boot configuration.""" + +import pathlib +import shutil +import subprocess +import tempfile +from abc import ABC, abstractmethod + +from ..builder import Logger +from ..apt_state import AptStateManager + + +def default_kernel_params(project: str) -> str: + if project == "ubuntukylin": + return ( + "file=/cdrom/preseed/ubuntu.seed locale=zh_CN " + "keyboard-configuration/layoutcode?=cn quiet splash --- " + ) + if project == "ubuntu-server": + return " --- " + else: + return " --- quiet splash" + + +class BaseBootConfigurator(ABC): + """Abstract base class for architecture-specific boot configurators. + + Subclasses must implement: + - extract_files(): Download and extract bootloader packages + - mkisofs_opts(): Return mkisofs command-line options + """ + + def __init__( + self, + logger: Logger, + apt_state: AptStateManager, + iso_root: pathlib.Path, + ) -> None: + self.logger = logger + self.apt_state = apt_state + self.iso_root = iso_root + + def create_dirs(self, workdir): + self.scratch = workdir.joinpath("boot-stuff") + self.scratch.mkdir(exist_ok=True) + self.boot_tree = self.scratch.joinpath("cd-boot-tree") + + def download_and_extract_package( + self, pkg_name: str, target_dir: pathlib.Path + ) -> None: + """Download a Debian package and extract its contents to target directory.""" + self.logger.log(f"downloading and extracting {pkg_name}") + target_dir.mkdir(exist_ok=True, parents=True) + with tempfile.TemporaryDirectory() as tdir_str: + tdir = pathlib.Path(tdir_str) + self.apt_state.download_direct(pkg_name, tdir) + [deb] = tdir.glob("*.deb") + dpkg_proc = subprocess.Popen( + ["dpkg-deb", "--fsys-tarfile", deb], stdout=subprocess.PIPE + ) + tar_proc = subprocess.Popen( + ["tar", "xf", "-", "-C", target_dir], stdin=dpkg_proc.stdout + ) + assert dpkg_proc.stdout is not None + dpkg_proc.stdout.close() + tar_proc.communicate() + + def copy_grub_modules( + self, src_dir: pathlib.Path, dest_dir: pathlib.Path, extensions: list[str] + ) -> None: + """Copy GRUB module files matching given extensions from src to dest.""" + for ext in extensions: + for file in src_dir.glob(ext): + shutil.copy(file, dest_dir) + + @abstractmethod + def extract_files(self) -> None: + """Download and extract bootloader packages to the boot tree. + + Each architecture must implement this to set up its specific bootloader files. + """ + ... + + @abstractmethod + def mkisofs_opts(self) -> list[str | pathlib.Path]: + """Return mkisofs command-line options for this architecture. + + Returns: + List of command-line options to pass to mkisofs/xorriso. + """ + ... + + def post_process_iso(self, iso_path: pathlib.Path) -> None: + """Post-process the ISO image after xorriso creates it.""" + + def make_bootable( + self, + workdir: pathlib.Path, + project: str, + capproject: str, + subarch: str, + hwe: bool, + ) -> None: + """Make the ISO bootable by extracting bootloader files.""" + self.project = project + self.humanproject = capproject.replace("-", " ") + self.subarch = subarch + self.hwe = hwe + self.create_dirs(workdir) + with self.logger.logged("configuring boot"): + self.extract_files() diff --git a/live-build/isobuilder/boot/grub.py b/live-build/isobuilder/boot/grub.py new file mode 100644 index 00000000..528135e4 --- /dev/null +++ b/live-build/isobuilder/boot/grub.py @@ -0,0 +1,103 @@ +"""GRUB boot configuration for multiple architectures.""" + +import pathlib +import shutil +from abc import abstractmethod + +from .base import BaseBootConfigurator + + +def copy_grub_common_files_to_boot_tree( + grub_dir: pathlib.Path, boot_tree: pathlib.Path +) -> None: + fonts_dir = boot_tree.joinpath("boot", "grub", "fonts") + fonts_dir.mkdir(parents=True, exist_ok=True) + + src = grub_dir.joinpath("usr", "share", "grub", "unicode.pf2") + dst = fonts_dir.joinpath("unicode.pf2") + shutil.copy(src, dst) + + +class GrubBootConfigurator(BaseBootConfigurator): + """Base class for architectures that use GRUB (all except S390X). + + Common GRUB functionality shared across AMD64, ARM64, PPC64EL, and RISC-V64. + Subclasses must implement generate_grub_config(). + """ + + def create_dirs(self, workdir): + super().create_dirs(workdir) + self.grub_dir = self.boot_tree.joinpath("grub") + + def setup_grub_common_files(self) -> None: + """Copy common GRUB files (fonts, etc.) to boot tree.""" + copy_grub_common_files_to_boot_tree(self.grub_dir, self.boot_tree) + + def write_grub_header( + self, grub_cfg: pathlib.Path, include_loadfont: bool = True + ) -> None: + """Write common GRUB config header (timeout, colors). + + Args: + grub_cfg: Path to grub.cfg file + include_loadfont: Whether to include 'loadfont unicode' + (not needed for RISC-V) + """ + with grub_cfg.open("a") as f: + f.write("set timeout=30\n\n") + if include_loadfont: + f.write("loadfont unicode\n\n") + f.write( + """set menu_color_normal=white/black +set menu_color_highlight=black/light-gray + +""" + ) + + def write_hwe_menu_entry( + self, + grub_cfg: pathlib.Path, + kernel_name: str, + kernel_params: str, + extra_params: str = "", + ) -> None: + """Write HWE kernel menu entry if HWE is enabled. + + Args: + grub_cfg: Path to grub.cfg file + kernel_name: Kernel binary name (vmlinuz or vmlinux) + kernel_params: Kernel parameters to append + extra_params: Additional parameters (e.g., console=tty0, $cmdline) + """ + if self.hwe: + with grub_cfg.open("a") as f: + f.write( + f"""menuentry "{self.humanproject} with the HWE kernel" {{ +\tset gfxpayload=keep +\tlinux\t/casper/hwe-{kernel_name} {extra_params}{kernel_params} +\tinitrd\t/casper/hwe-initrd +}} +""" + ) + + @abstractmethod + def generate_grub_config(self) -> None: + """Generate grub.cfg configuration file. + + Each GRUB-based architecture must implement this to create its + specific GRUB configuration. + """ + ... + + def make_bootable( + self, + workdir: pathlib.Path, + project: str, + capproject: str, + subarch: str, + hwe: bool, + ) -> None: + """Make the ISO bootable by extracting files and generating GRUB config.""" + super().make_bootable(workdir, project, capproject, subarch, hwe) + with self.logger.logged("generating grub config"): + self.generate_grub_config() diff --git a/live-build/isobuilder/boot/ppc64el.py b/live-build/isobuilder/boot/ppc64el.py new file mode 100644 index 00000000..db731591 --- /dev/null +++ b/live-build/isobuilder/boot/ppc64el.py @@ -0,0 +1,75 @@ +"""PowerPC 64-bit Little Endian architecture boot configuration.""" + +import pathlib +import shutil + +from .grub import GrubBootConfigurator +from .base import default_kernel_params + + +class PPC64ELBootConfigurator(GrubBootConfigurator): + """Boot setup for PowerPC 64-bit Little Endian architecture.""" + + def mkisofs_opts(self) -> list[str | pathlib.Path]: + """Return mkisofs options for PPC64EL.""" + # Add cd-boot-tree to the ISO + return [self.boot_tree] + + def extract_files(self) -> None: + """Download and extract bootloader packages for PPC64EL.""" + self.logger.log("extracting PPC64EL boot files") + + # Download and extract bootloader packages + self.download_and_extract_package("grub2-common", self.grub_dir) + self.download_and_extract_package("grub-ieee1275-bin", self.grub_dir) + + # Add common files for GRUB to tree + self.setup_grub_common_files() + + # Add IEEE1275 ppc boot files + ppc_dir = self.boot_tree.joinpath("ppc") + ppc_dir.mkdir(parents=True, exist_ok=True) + + grub_boot_dir = self.boot_tree.joinpath("boot", "grub", "powerpc-ieee1275") + grub_boot_dir.mkdir(parents=True, exist_ok=True) + + src_grub_dir = self.grub_dir.joinpath("usr", "lib", "grub", "powerpc-ieee1275") + + # Copy bootinfo.txt to ppc directory + shutil.copy( + src_grub_dir.joinpath("bootinfo.txt"), ppc_dir.joinpath("bootinfo.txt") + ) + + # Copy eltorito.elf to boot/grub as powerpc.elf + shutil.copy( + src_grub_dir.joinpath("eltorito.elf"), + self.boot_tree.joinpath("boot", "grub", "powerpc.elf"), + ) + + # Copy GRUB modules + self.copy_grub_modules(src_grub_dir, grub_boot_dir, ["*.mod", "*.lst"]) + + def generate_grub_config(self) -> None: + """Generate grub.cfg for PPC64EL.""" + kernel_params = default_kernel_params(self.project) + + grub_cfg = self.grub_dir.joinpath("grub.cfg") + + # Write common GRUB header + self.write_grub_header(grub_cfg) + + # Main menu entry + with grub_cfg.open("a") as f: + f.write( + f"""menuentry "Try or Install {self.humanproject}" {{ +\tset gfxpayload=keep +\tlinux\t/casper/vmlinux quiet {kernel_params} +\tinitrd\t/casper/initrd +}} +""" + ) + + # HWE kernel option if available + self.write_hwe_menu_entry( + grub_cfg, "vmlinux", kernel_params, extra_params="quiet " + ) diff --git a/live-build/isobuilder/boot/riscv64.py b/live-build/isobuilder/boot/riscv64.py new file mode 100644 index 00000000..6b52f1ec --- /dev/null +++ b/live-build/isobuilder/boot/riscv64.py @@ -0,0 +1,223 @@ +"""RISC-V 64-bit architecture boot configuration.""" + +import pathlib +import shutil + +from .grub import GrubBootConfigurator + + +def copy_unsigned_monolithic_grub_to_boot_tree( + grub_dir: pathlib.Path, efi_suffix: str, grub_target: str, boot_tree: pathlib.Path +) -> None: + efi_boot_dir = boot_tree.joinpath("EFI", "boot") + efi_boot_dir.mkdir(parents=True, exist_ok=True) + + shutil.copy( + grub_dir.joinpath( + "usr", + "lib", + "grub", + f"{grub_target}-efi", + "monolithic", + f"gcd{efi_suffix}.efi", + ), + efi_boot_dir.joinpath(f"boot{efi_suffix}.efi"), + ) + + grub_boot_dir = boot_tree.joinpath("boot", "grub", f"{grub_target}-efi") + grub_boot_dir.mkdir(parents=True, exist_ok=True) + + src_grub_dir = grub_dir.joinpath("usr", "lib", "grub", f"{grub_target}-efi") + for mod_file in src_grub_dir.glob("*.mod"): + shutil.copy(mod_file, grub_boot_dir) + for lst_file in src_grub_dir.glob("*.lst"): + shutil.copy(lst_file, grub_boot_dir) + + +class RISCV64BootConfigurator(GrubBootConfigurator): + """Boot setup for RISC-V 64-bit architecture.""" + + def mkisofs_opts(self) -> list[str | pathlib.Path]: + """Return mkisofs options for RISC-V64.""" + efi_img = self.scratch.joinpath("efi.img") + + return [ + "-joliet", + "on", + "-compliance", + "joliet_long_names", + "--append_partition", + "2", + "0xef", + efi_img, + "-boot_image", + "any", + "partition_offset=10240", + "-boot_image", + "any", + "partition_cyl_align=all", + "-boot_image", + "any", + "efi_path=--interval:appended_partition_2:all::", + "-boot_image", + "any", + "appended_part_as=gpt", + "-boot_image", + "any", + "cat_path=/boot/boot.cat", + "-fs", + "64m", + ] + + def extract_files(self) -> None: + """Download and extract bootloader packages for RISC-V64.""" + self.logger.log("extracting RISC-V64 boot files") + u_boot_dir = self.scratch.joinpath("u-boot-sifive") + + # Download and extract bootloader packages + self.download_and_extract_package("grub2-common", self.grub_dir) + self.download_and_extract_package("grub-efi-riscv64-bin", self.grub_dir) + self.download_and_extract_package("grub-efi-riscv64-unsigned", self.grub_dir) + self.download_and_extract_package("u-boot-sifive", u_boot_dir) + + # Add GRUB to tree + self.setup_grub_common_files() + copy_unsigned_monolithic_grub_to_boot_tree( + self.grub_dir, "riscv64", "riscv64", self.boot_tree + ) + + # Extract DTBs to tree + self.logger.log("extracting device tree files") + kernel_layer = self.scratch.joinpath("kernel-layer") + squashfs_path = self.iso_root.joinpath( + "casper", "ubuntu-server-minimal.squashfs" + ) + + # Extract device tree firmware from squashfs + self.logger.run( + [ + "unsquashfs", + "-no-xattrs", + "-d", + kernel_layer, + squashfs_path, + "usr/lib/firmware", + ], + check=True, + ) + + # Copy DTBs if they exist + dtb_dir = self.boot_tree.joinpath("dtb") + dtb_dir.mkdir(parents=True, exist_ok=True) + + firmware_dir = kernel_layer.joinpath("usr", "lib", "firmware") + device_tree_files = list(firmware_dir.glob("*/device-tree/*")) + + if device_tree_files: + for dtb_file in device_tree_files: + if dtb_file.is_file(): + shutil.copy(dtb_file, dtb_dir) + + # Clean up kernel layer + shutil.rmtree(kernel_layer) + + # Copy tree contents to live-media rootfs + self.logger.run(["cp", "-aT", self.boot_tree, self.iso_root], check=True) + + # Create ESP image with GRUB and dtbs + efi_img = self.scratch.joinpath("efi.img") + self.logger.run( + ["mkfs.msdos", "-n", "ESP", "-C", "-v", efi_img, "32768"], check=True + ) + + # Add EFI files to ESP + efi_dir = self.boot_tree.joinpath("EFI") + self.logger.run(["mcopy", "-s", "-i", efi_img, efi_dir, "::/."], check=True) + + # Add DTBs to ESP + self.logger.run(["mcopy", "-s", "-i", efi_img, dtb_dir, "::/."], check=True) + + def generate_grub_config(self) -> None: + """Generate grub.cfg for RISC-V64.""" + grub_dir = self.iso_root.joinpath("boot", "grub") + grub_dir.mkdir(parents=True, exist_ok=True) + + grub_cfg = grub_dir.joinpath("grub.cfg") + + # Write GRUB header (without loadfont for RISC-V) + self.write_grub_header(grub_cfg, include_loadfont=False) + + # Main menu entry + with grub_cfg.open("a") as f: + f.write( + f"""menuentry "Try or Install {self.humanproject}" {{ +\tset gfxpayload=keep +\tlinux\t/casper/vmlinux efi=debug sysctl.kernel.watchdog_thresh=60 --- +\tinitrd\t/casper/initrd +}} +""" + ) + + # HWE kernel option if available + self.write_hwe_menu_entry( + grub_cfg, + "vmlinux", + "---", + extra_params="efi=debug sysctl.kernel.watchdog_thresh=60 ", + ) + + def post_process_iso(self, iso_path: pathlib.Path) -> None: + """Add GPT partitions with U-Boot for SiFive Unmatched board. + + The SiFive Unmatched board needs a GPT table containing U-Boot in + order to boot. U-Boot does not currently support booting from a CD, + so the GPT table also contains an entry pointing to the ESP so that + U-Boot can find it. + """ + u_boot_dir = self.scratch.joinpath( + "u-boot-sifive", "usr", "lib", "u-boot", "sifive_unmatched" + ) + self.logger.run( + [ + "sgdisk", + iso_path, + "--set-alignment=2", + "-d", + "1", + "-n", + "1:2082:10273", + "-c", + "1:loader2", + "-t", + "1:2E54B353-1271-4842-806F-E436D6AF6985", + "-n", + "3:10274:12321", + "-c", + "3:loader1", + "-t", + "3:5B193300-FC78-40CD-8002-E86C45580B47", + "-c", + "2:ESP", + "-r=2:3", + ], + ) + self.logger.run( + [ + "dd", + f"if={u_boot_dir / 'u-boot.itb'}", + f"of={iso_path}", + "bs=512", + "seek=2082", + "conv=notrunc", + ], + ) + self.logger.run( + [ + "dd", + f"if={u_boot_dir / 'u-boot-spl.bin'}", + f"of={iso_path}", + "bs=512", + "seek=10274", + "conv=notrunc", + ], + ) diff --git a/live-build/isobuilder/boot/s390x.py b/live-build/isobuilder/boot/s390x.py new file mode 100644 index 00000000..0b8edf98 --- /dev/null +++ b/live-build/isobuilder/boot/s390x.py @@ -0,0 +1,206 @@ +"""IBM S/390 architecture boot configuration.""" + +import pathlib +import shutil +import struct + +from .base import BaseBootConfigurator + + +README_dot_boot = """\ +About the S/390 installation CD +=============================== + +It is possible to "boot" the installation system off this CD using +the files provided in the /boot directory. + +Although you can boot the installer from this CD, the installation +itself is *not* actually done from the CD. Once the initrd is loaded, +the installer will ask you to configure your network connection and +uses the network-console component to allow you to continue the +installation over SSH. The rest of the installation is done over the +network: all installer components and Debian packages are retrieved +from a mirror. + +Instead of SSH, one can also use the ASCII terminal available in HMC. + +Exporting full .iso contents (including the hidden .disk directory) +allows one to use the result as a valid mirror for installation. +""" + +ubuntu_dot_exec = """\ +/* REXX EXEC TO IPL Ubuntu for */ +/* z Systems FROM THE VM READER. */ +/* */ +'CP CLOSE RDR' +'PURGE RDR ALL' +'SPOOL PUNCH * RDR' +'PUNCH KERNEL UBUNTU * (NOHEADER' +'PUNCH PARMFILE UBUNTU * (NOHEADER' +'PUNCH INITRD UBUNTU * (NOHEADER' +'CHANGE RDR ALL KEEP NOHOLD' +'CP IPL 000C CLEAR' +""" + +ubuntu_dot_ins = """\ +* Ubuntu for IBM Z (default kernel) +kernel.ubuntu 0x00000000 +initrd.off 0x0001040c +initrd.siz 0x00010414 +parmfile.ubuntu 0x00010480 +initrd.ubuntu 0x01000000 +""" + + +def gen_s390_cd_kernel( + kernel: pathlib.Path, initrd: pathlib.Path, cmdline: str, outfile: pathlib.Path +) -> None: + """Generate a bootable S390X CD kernel image. + + This is a Python translation of gen-s390-cd-kernel.pl from debian-cd. + It creates a bootable image for S/390 architecture by combining kernel, + initrd, and boot parameters in a specific format. + """ + # Calculate sizes + initrd_size = initrd.stat().st_size + + # The initrd is placed at a fixed offset of 16 MiB + initrd_offset = 0x1000000 + + # Calculate total boot image size (rounded up to 4K blocks) + boot_size = ((initrd_offset + initrd_size) >> 12) + 1 + boot_size = boot_size << 12 + + # Validate cmdline length (max 896 bytes) + if len(cmdline) >= 896: + raise ValueError(f"Kernel commandline too long ({len(cmdline)} bytes)") + + # Create output file and fill with zeros + with outfile.open("wb") as out_fh: + # Fill entire file with zeros + out_fh.write(b"\x00" * boot_size) + + # Copy kernel to offset 0 + out_fh.seek(0) + with kernel.open("rb") as kernel_fh: + out_fh.write(kernel_fh.read()) + + # Copy initrd to offset 0x1000000 (16 MiB) + out_fh.seek(initrd_offset) + with initrd.open("rb") as initrd_fh: + out_fh.write(initrd_fh.read()) + + # Write boot loader control value at offset 4 + # This tells the S/390 boot loader where to find the kernel + out_fh.seek(4) + out_fh.write(struct.pack("!I", 0x80010000)) + + # Write kernel command line at offset 0x10480 + out_fh.seek(0x10480) + out_fh.write(cmdline.encode("utf-8")) + + # Write initrd parameters + # Initrd offset at 0x1040C + out_fh.seek(0x1040C) + out_fh.write(struct.pack("!I", initrd_offset)) + + # Initrd size at 0x10414 + out_fh.seek(0x10414) + out_fh.write(struct.pack("!I", initrd_size)) + + +class S390XBootConfigurator(BaseBootConfigurator): + """Boot setup for IBM S/390 architecture.""" + + def mkisofs_opts(self) -> list[str | pathlib.Path]: + """Return mkisofs options for S390X.""" + return [ + "-J", + "-no-emul-boot", + "-b", + "boot/ubuntu.ikr", + ] + + def extract_files(self) -> None: + """Set up boot files for S390X.""" + self.logger.log("extracting S390X boot files") + boot_dir = self.iso_root.joinpath("boot") + boot_dir.mkdir(parents=True, exist_ok=True) + + # Copy static .ins & exec scripts, docs from data directory + self.iso_root.joinpath("README.boot").write_text(README_dot_boot) + boot_dir.joinpath("ubuntu.exec").write_text(ubuntu_dot_exec) + boot_dir.joinpath("ubuntu.ins").write_text(ubuntu_dot_ins) + + # Move kernel image to the name used in .ins & exec scripts + kernel_src = self.iso_root.joinpath("casper", "vmlinuz") + kernel_dst = boot_dir.joinpath("kernel.ubuntu") + kernel_src.replace(kernel_dst) + + # Move initrd to the name used in .ins & exec scripts + initrd_src = self.iso_root.joinpath("casper", "initrd") + initrd_dst = boot_dir.joinpath("initrd.ubuntu") + initrd_src.replace(initrd_dst) + + # Compute initrd offset & size, store in files used by .ins & exec scripts + # Offset is always 0x1000000 (16 MiB) + initrd_offset_file = boot_dir.joinpath("initrd.off") + with initrd_offset_file.open("wb") as f: + f.write(struct.pack("!I", 0x1000000)) + + # Size is the actual size of the initrd + initrd_size = initrd_dst.stat().st_size + initrd_size_file = boot_dir.joinpath("initrd.siz") + with initrd_size_file.open("wb") as f: + f.write(struct.pack("!I", initrd_size)) + + # Compute cmdline, store in parmfile used by .ins & exec scripts + parmfile = boot_dir.joinpath("parmfile.ubuntu") + with parmfile.open("w") as f: + f.write(" --- ") + + # Generate secondary top-level ubuntu.ins file + # This transforms lines not starting with * by prepending "boot/" + ubuntu_ins_src = boot_dir.joinpath("ubuntu.ins") + ubuntu_ins_dst = self.iso_root.joinpath("ubuntu.ins") + if ubuntu_ins_src.exists(): + self.logger.run( + ["sed", "-e", "s,^[^*],boot/&,g", ubuntu_ins_src], + stdout=ubuntu_ins_dst.open("w"), + check=True, + ) + + # Generate QEMU-KVM boot image using gen_s390_cd_kernel + cmdline = parmfile.read_text().strip() + ikr_file = boot_dir.joinpath("ubuntu.ikr") + gen_s390_cd_kernel(kernel_dst, initrd_dst, cmdline, ikr_file) + + # Extract bootloader signing certificate + installed_pem = pathlib.Path("/usr/lib/s390-tools/stage3.pem") + squashfs_root = self.iso_root.joinpath("squashfs-root") + squashfs_path = self.iso_root.joinpath( + "casper", "ubuntu-server-minimal.squashfs" + ) + + if squashfs_path.exists(): + self.logger.run( + [ + "unsquashfs", + "-no-xattrs", + "-i", + "-d", + squashfs_root, + squashfs_path, + installed_pem, + ], + check=True, + ) + + # Move certificate to iso root + cert_src = squashfs_root.joinpath(str(installed_pem).lstrip("/")) + cert_dst = self.iso_root.joinpath("ubuntu.pem") + if cert_src.exists(): + cert_src.replace(cert_dst) + + # Clean up squashfs extraction + shutil.rmtree(squashfs_root) diff --git a/live-build/isobuilder/boot/uefi.py b/live-build/isobuilder/boot/uefi.py new file mode 100644 index 00000000..ca991c1c --- /dev/null +++ b/live-build/isobuilder/boot/uefi.py @@ -0,0 +1,168 @@ +"""UEFI boot configuration for AMD64 and ARM64 architectures.""" + +import pathlib +import shutil + +from ..builder import Logger +from .grub import GrubBootConfigurator + + +def copy_signed_shim_grub_to_boot_tree( + shim_dir: pathlib.Path, + grub_dir: pathlib.Path, + efi_suffix: str, + grub_target: str, + boot_tree: pathlib.Path, +) -> None: + efi_boot_dir = boot_tree.joinpath("EFI", "boot") + efi_boot_dir.mkdir(parents=True, exist_ok=True) + + shutil.copy( + shim_dir.joinpath("usr", "lib", "shim", f"shim{efi_suffix}.efi.signed.latest"), + efi_boot_dir.joinpath(f"boot{efi_suffix}.efi"), + ) + shutil.copy( + shim_dir.joinpath("usr", "lib", "shim", f"mm{efi_suffix}.efi"), + efi_boot_dir.joinpath(f"mm{efi_suffix}.efi"), + ) + shutil.copy( + grub_dir.joinpath( + "usr", + "lib", + "grub", + f"{grub_target}-efi-signed", + f"gcd{efi_suffix}.efi.signed", + ), + efi_boot_dir.joinpath(f"grub{efi_suffix}.efi"), + ) + + grub_boot_dir = boot_tree.joinpath("boot", "grub", f"{grub_target}-efi") + grub_boot_dir.mkdir(parents=True, exist_ok=True) + + src_grub_dir = grub_dir.joinpath("usr", "lib", "grub", f"{grub_target}-efi") + for mod_file in src_grub_dir.glob("*.mod"): + shutil.copy(mod_file, grub_boot_dir) + for lst_file in src_grub_dir.glob("*.lst"): + shutil.copy(lst_file, grub_boot_dir) + + +def create_eltorito_esp_image( + logger: Logger, boot_tree: pathlib.Path, target_file: pathlib.Path +) -> None: + logger.log("creating El Torito ESP image") + efi_dir = boot_tree.joinpath("EFI") + + # Calculate size: du -s --apparent-size --block-size=1024 + 1024 + result = logger.run( + ["du", "-s", "--apparent-size", "--block-size=1024", efi_dir], + capture_output=True, + text=True, + check=True, + ) + size_kb = int(result.stdout.split()[0]) + 1024 + + # Create filesystem: mkfs.msdos -n ESP -C -v + logger.run( + ["mkfs.msdos", "-n", "ESP", "-C", "-v", target_file, str(size_kb)], + check=True, + ) + + # Copy files: mcopy -s -i target_file EFI ::/. + logger.run(["mcopy", "-s", "-i", target_file, efi_dir, "::/."], check=True) + + +class UEFIBootConfigurator(GrubBootConfigurator): + """Base class for UEFI-based architectures (AMD64, ARM64). + + Subclasses should set: + - efi_suffix: EFI binary suffix (e.g., "x64", "aa64") + - grub_target: GRUB target name (e.g., "x86_64", "arm64") + """ + + # Subclasses must override these + efi_suffix: str = "" + grub_target: str = "" + arch: str = "" + + def create_dirs(self, workdir): + super().create_dirs(workdir) + self.shim_dir = self.boot_tree.joinpath("shim") + + def get_uefi_grub_packages(self) -> list[str]: + """Return list of UEFI GRUB packages to download.""" + return [ + "grub2-common", + f"grub-efi-{self.arch}-bin", + f"grub-efi-{self.arch}-signed", + ] + + def extract_uefi_files(self) -> None: + """Extract common UEFI files to boot tree.""" + # Download UEFI packages + self.download_and_extract_package("shim-signed", self.shim_dir) + for pkg in self.get_uefi_grub_packages(): + self.download_and_extract_package(pkg, self.grub_dir) + + # Add common files for GRUB to tree + self.setup_grub_common_files() + + # Add EFI GRUB to tree + copy_signed_shim_grub_to_boot_tree( + self.shim_dir, + self.grub_dir, + self.efi_suffix, + self.grub_target, + self.boot_tree, + ) + + # Create ESP image for El-Torito catalog and hybrid boot + create_eltorito_esp_image( + self.logger, self.boot_tree, self.scratch.joinpath("cd-boot-efi.img") + ) + + def write_uefi_menu_entries(self, grub_cfg: pathlib.Path) -> None: + """Write UEFI firmware menu entries.""" + with grub_cfg.open("a") as f: + f.write( + """menuentry 'Boot from next volume' { +\texit 1 +} +menuentry 'UEFI Firmware Settings' { +\tfwsetup +} +""" + ) + + def get_uefi_mkisofs_opts(self) -> list[str | pathlib.Path]: + """Return common UEFI mkisofs options.""" + # To make our ESP / El-Torito image compliant with MBR/GPT standards, + # we first append it as a partition and then point the El Torito at + # it. See https://lists.debian.org/debian-cd/2019/07/msg00007.html + opts: list[str | pathlib.Path] = [ + "-append_partition", + "2", + "0xef", + self.scratch.joinpath("cd-boot-efi.img"), + "-appended_part_as_gpt", + ] + + # Some BIOSes ignore removable disks with no partitions marked bootable + # in the MBR. Make sure our protective MBR partition is marked bootable. + opts.append("--mbr-force-bootable") + + # Start a new entry in the el torito boot catalog + opts.append("-eltorito-alt-boot") + + # Specify where the el torito UEFI boot image "name". We use a special + # syntax available in latest xorriso to point at our newly-created + # partition. + opts.extend(["-e", "--interval:appended_partition_2:all::"]) + + # Whether to emulate a floppy or not is a per-boot-catalog-entry + # thing, so we need to say it again. + opts.append("-no-emul-boot") + + # Create a partition table entry that covers the iso9660 filesystem + opts.extend(["-partition_offset", "16"]) + + return opts