]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
Add back BIOS support using grub
authorDaan De Meyer <daan.j.demeyer@gmail.com>
Mon, 14 Aug 2023 11:52:29 +0000 (13:52 +0200)
committerDaan De Meyer <daan.j.demeyer@gmail.com>
Mon, 21 Aug 2023 10:35:34 +0000 (12:35 +0200)
Let's add back support for booting on BIOS using grub. This comes
with the following limitations:

- grub does not support UKIs on BIOS, so we set up the individual
components instead
- grub cannot search partitions by PARTUUID, so we're forced to have
it find the ESP by partition number instead.

We opt to generate grub.cfg ourselves instead of relying on grub-mkconfig.
grub-mkconfig is basically like kernel-install but for grub configuration,
it has a ton of distro specific cruft that we want to ignore, so we simply
don't use it and generate the grub configuration ourselves.

To allow for unprivileged installation of grub, we can't use grub-install
as it insists on opening the root device and probing its filesystem, which
isn't possible unprivileged. Instead, we run grub-mkimage and grub-bios-setup
ourselves, and manually copy the required files to the ESP.

We use the ESP to store the kernels, initrds and grub config. In the event
that grub adds support for UKIs on BIOS in the future, we can simply modify
the generated grub configuration to use our generated UKIs instead.

.github/mkosi.conf.d/10-common.conf
.github/mkosi.conf.d/20-arch.conf
.github/mkosi.conf.d/20-centos.conf
.github/mkosi.conf.d/20-debian.conf
.github/mkosi.conf.d/20-fedora.conf
.github/mkosi.conf.d/20-opensuse.conf
.github/mkosi.conf.d/20-ubuntu.conf
.github/workflows/ci.yml
mkosi/__init__.py
mkosi/config.py
mkosi/resources/mkosi.md

index 35441cfb1242f06ef3df04ef2cc92c819a68ee63..7a182eaf24a29bb64808891b713bd74d3885a710 100644 (file)
@@ -7,6 +7,7 @@ KernelCommandLine=console=ttyS0
 
 [Content]
 Bootable=yes
+BiosBootloader=grub
 
 [Host]
 Autologin=yes
index d873a750a781bf97edc60f83838482f646631d71..d14919a124c6b39f39529d7bf388b553197e0b9a 100644 (file)
@@ -5,3 +5,4 @@ Distribution=arch
 Packages=linux
          systemd
          base
+         grub
index c794d3235995e4a71003e38f11f28031f4b8ce49..dafbd2916cd5d026b97ed11de129d217e6a87df0 100644 (file)
@@ -8,3 +8,4 @@ Packages=kernel-core
          systemd
          systemd-boot
          udev
+         grub2-pc
index 2dead04649da61d8e0608cb120419affc6432647..3d7f821f0cd5660b3b74cb651627a4f7834f5df8 100644 (file)
@@ -9,3 +9,4 @@ Packages=linux-image-cloud-amd64
          udev
          dbus
          tzdata
+         grub-pc
index 845246b5ad0973fc2b6c64a318146a5215ffb13a..81c63fcf57b6ef5cc74d67198a337a168731fb58 100644 (file)
@@ -7,3 +7,4 @@ Packages=kernel-core
          systemd-boot
          udev
          util-linux
+         grub2-pc
index 6e91e5c51f5a1c52178551627081a171e94153bf..14279b66b82cbd2b18d98f0ecb3371fc4a925cc8 100644 (file)
@@ -6,3 +6,4 @@ Packages=kernel-kvmsmall
          systemd
          systemd-boot
          udev
+         grub2-i386-pc
index 9d9c4ba16025d4a1cd7be0057c58b6ec41408cfd..2c8959cc28c212621fefac318a185bc236a3aae1 100644 (file)
@@ -15,3 +15,4 @@ Packages=linux-kvm
          udev
          dbus
          tzdata
+         grub-pc
index ad04abe90cb2689b97459f95fc0b96d127d5bc0c..f68d9297dd09b8b504a5e2ae5bcadb03a534daee 100644 (file)
@@ -141,3 +141,7 @@ jobs:
     - name: Boot ${{ matrix.distro }}/${{ matrix.format }} UEFI
       if: matrix.format == 'disk'
       run: timeout -k 30 10m mkosi --debug qemu
+
+    - name: Boot ${{ matrix.distro }}/${{ matrix.format }} BIOS
+      if: matrix.format == 'disk'
+      run: timeout -k 30 10m mkosi --debug --qemu-bios qemu
index fb4b6101391b52e5be2d40ee5fa8b08233cc0ab6..0ed91837e08e9b6b135319bd67e01d112db56cf6 100644 (file)
@@ -23,6 +23,7 @@ from typing import Any, ContextManager, Mapping, Optional, TextIO, Union
 
 from mkosi.archive import extract_tar, make_cpio, make_tar
 from mkosi.config import (
+    BiosBootloader,
     Bootloader,
     Compression,
     ConfigFeature,
@@ -40,7 +41,7 @@ from mkosi.config import (
 from mkosi.install import add_dropin_config_from_resource
 from mkosi.installer import clean_package_manager_metadata, package_manager_scripts
 from mkosi.kmod import gen_required_kernel_modules, process_kernel_modules
-from mkosi.log import complete_step, die, log_step
+from mkosi.log import ARG_DEBUG, complete_step, die, log_step
 from mkosi.manifest import Manifest
 from mkosi.mounts import mount_overlay, mount_passwd, mount_usr
 from mkosi.pager import page
@@ -66,18 +67,22 @@ from mkosi.versioncomp import GenericVersion
 class Partition:
     type: str
     uuid: str
-    split_path: Optional[Path] = None
-    roothash: Optional[str] = None
+    partno: Optional[int]
+    split_path: Optional[Path]
+    roothash: Optional[str]
 
     @classmethod
     def from_dict(cls, dict: Mapping[str, Any]) -> "Partition":
         return cls(
             type=dict["type"],
             uuid=dict["uuid"],
+            partno=int(partno) if (partno := dict.get("partno")) else None,
             split_path=Path(p) if ((p := dict.get("split_path")) and p != "-") else None,
             roothash=dict.get("roothash"),
         )
 
+    GRUB_BOOT_PARTITION_UUID = "21686148-6449-6e6f-744e-656564454649"
+
 
 @contextlib.contextmanager
 def mount_image(state: MkosiState) -> Iterator[None]:
@@ -548,6 +553,225 @@ def install_systemd_boot(state: MkosiState) -> None:
                      state.workspace / "mkosi.esl"])
 
 
+def find_grub_bios_directory(state: MkosiState) -> Optional[Path]:
+    for d in ("usr/lib/grub/i386-pc", "usr/share/grub2/i386-pc"):
+        if (p := state.root / d).exists() and any(p.iterdir()):
+            return p
+
+    return None
+
+
+def find_grub_binary(state: MkosiState, binary: str) -> Optional[Path]:
+    path = ":".join(os.fspath(p) for p in [state.root / "usr/bin", state.root / "usr/sbin"])
+
+    assert "grub" in binary and not "grub2" in binary
+
+    path = shutil.which(binary, path=path) or shutil.which(binary.replace("grub", "grub2"), path=path)
+    if not path:
+        return None
+
+    return Path("/") / Path(path).relative_to(state.root)
+
+
+def find_grub_prefix(state: MkosiState) -> Optional[str]:
+    path = find_grub_binary(state, "grub-mkimage")
+    if path is None:
+        return None
+
+    return "grub2" if "grub2" in os.fspath(path) else "grub"
+
+
+def want_grub_bios(state: MkosiState, partitions: Sequence[Partition] = ()) -> bool:
+    if state.config.bootable == ConfigFeature.disabled:
+        return False
+
+    if state.config.output_format != OutputFormat.disk:
+        return False
+
+    if state.config.bios_bootloader != BiosBootloader.grub:
+        return False
+
+    have = find_grub_bios_directory(state) is not None
+    if not have and state.config.bootable == ConfigFeature.enabled:
+        die("A BIOS bootable image with grub was requested but grub for BIOS is not installed")
+
+    bios = any(p.type == Partition.GRUB_BOOT_PARTITION_UUID for p in partitions)
+    if partitions and not bios and state.config.bootable == ConfigFeature.enabled:
+        die("A BIOS bootable image with grub was requested but no BIOS Boot Partition was configured")
+
+    esp = any(p.type == "esp" for p in partitions)
+    if partitions and not esp and state.config.bootable == ConfigFeature.enabled:
+        die("A BIOS bootable image with grub was requested but no ESP partition was configured")
+
+    root = any(p.type.startswith("root") or p.type.startswith("usr") for p in partitions)
+    if partitions and not root and state.config.bootable == ConfigFeature.enabled:
+        die("A BIOS bootable image with grub was requested but no root or usr partition was configured")
+
+    installed = True
+
+    for binary in ("grub-mkimage", "grub-bios-setup"):
+        path = find_grub_binary(state, binary)
+        if path is not None:
+            continue
+
+        if state.config.bootable == ConfigFeature.enabled:
+            die(f"A BIOS bootable image with grub was requested but {binary} was not found")
+
+        installed = False
+
+    return (have and bios and esp and root and installed) if partitions else have
+
+
+def prepare_grub_config(state: MkosiState) -> Optional[Path]:
+    prefix = find_grub_prefix(state)
+    if not prefix:
+        return None
+
+    config = state.root / "efi" / prefix / "grub.cfg"
+    with umask(~0o700):
+        config.parent.mkdir(exist_ok=True)
+
+    # For some unknown reason, if we don't set the timeout to zero, grub never leaves its menu, so we default
+    # to a zero timeout, but only if the config file hasn't been provided by the user.
+    if not config.exists():
+        with umask(~0o600), config.open("w") as f:
+            f.write("set timeout=0\n")
+
+    return config
+
+
+def prepare_grub_bios(state: MkosiState, partitions: Sequence[Partition]) -> None:
+    if not want_grub_bios(state, partitions):
+        return
+
+    config = prepare_grub_config(state)
+    assert config
+
+    root = finalize_roothash(partitions)
+    if not root:
+        root = next((f"root=PARTUUID={p.uuid}" for p in partitions if p.type.startswith("root")), None)
+    if not root:
+        root = next((f"mount.usr=PARTUUID={p.uuid}" for p in partitions if p.type.startswith("usr")), None)
+
+    assert root
+
+    initrd = build_initrd(state)
+
+    dst = state.root / "efi" / state.config.distribution.name
+    with umask(~0o700):
+        dst.mkdir(exist_ok=True)
+
+    initrd = Path(shutil.copy2(initrd, dst / "initrd"))
+
+    with config.open("a") as f:
+        f.write('if [ "${grub_platform}" == "pc" ]; then\n')
+
+        for kver, kimg in gen_kernel_images(state):
+            kdst = dst / kver
+            with umask(~0o700):
+                kdst.mkdir(exist_ok=True)
+
+            kmods = build_kernel_modules_initrd(state, kver)
+
+            with umask(~0o600):
+                kimg = Path(shutil.copy2(state.root / kimg, kdst / "vmlinuz"))
+                kmods = Path(shutil.copy2(kmods, kdst / "kmods"))
+
+                f.write(
+                    textwrap.dedent(
+                        f"""\
+                        menuentry "{state.config.distribution}-{kver}" {{
+                            linux /{kimg.relative_to(state.root / "efi")} {root} {" ".join(state.config.kernel_command_line)}
+                            initrd /{initrd.relative_to(state.root / "efi")} /{kmods.relative_to(state.root / "efi")}
+                        }}
+                        """
+                    )
+                )
+
+        f.write('fi\n')
+
+    # grub-install insists on opening the root partition device to probe it's filesystem which requires root
+    # so we're forced to reimplement its functionality. Luckily that's pretty simple, run grub-mkimage to
+    # generate the required core.img and copy the relevant files to the ESP.
+
+    mkimage = find_grub_binary(state, "grub-mkimage")
+    assert mkimage
+
+    directory = find_grub_bios_directory(state)
+    assert directory
+
+    prefix = find_grub_prefix(state)
+    assert prefix
+
+    esp = next(p for p in partitions if p.type == "esp")
+
+    dst = state.root / "efi" / prefix / "i386-pc"
+    dst.mkdir(parents=True, exist_ok=True)
+
+    bwrap([mkimage,
+           "--directory", directory,
+           # What we really want to do is use grub's search utility in an embedded config file to search for
+           # the ESP by its type UUID. Unfortunately, grub's search command only supports searching by
+           # filesystem UUID and filesystem label, which don't work for us. So for now, we hardcode the
+           # partition number of the ESP, but only very recent systemd-repart will output that information,
+           # so if we're using older systemd-repart, we assume the ESP is the first partition.
+           "--prefix", f"(hd0,gpt{esp.partno + 1 if esp.partno is not None else 1})/{prefix}",
+           "--output", dst / "core.img",
+           "--format", "i386-pc",
+           *(["--verbose"] if ARG_DEBUG.get() else []),
+           # Modules required to find and read from the ESP which has all the other modules.
+           "fat",
+           "part_gpt",
+           "biosdisk"],
+          options=["--bind", state.root / "usr", "/usr"])
+
+    for p in directory.glob("*.mod"):
+        shutil.copy2(p, dst)
+
+    for p in directory.glob("*.lst"):
+        shutil.copy2(p, dst)
+
+    shutil.copy2(directory / "modinfo.sh", dst)
+    shutil.copy2(directory / "boot.img", dst)
+
+    dst = state.root / "efi" / prefix / "fonts"
+    dst.mkdir()
+
+    for prefix in ("grub", "grub2"):
+        unicode = state.root / "usr/share" / prefix / "unicode.pf2"
+        if unicode.exists():
+            shutil.copy2(unicode, dst)
+
+
+def install_grub_bios(state: MkosiState, partitions: Sequence[Partition]) -> None:
+    if not want_grub_bios(state, partitions):
+        return
+
+    setup = find_grub_binary(state, "grub-bios-setup")
+    assert setup
+
+    prefix = find_grub_prefix(state)
+    assert prefix
+
+    # grub-bios-setup insists on being able to open the root device that --directory is located on, which
+    # needs root privileges. However, it only uses the root device when it is unable to embed itself in the
+    # bios boot partition. To make installation work unprivileged, we trick grub to think that the root
+    # device is our image by mounting over its /proc/self/mountinfo file (where it gets its information from)
+    # with our own file correlating the root directory to our image file.
+    mountinfo = state.workspace / "mountinfo"
+    mountinfo.write_text(f"1 0 1:1 / / - fat {state.staging / state.config.output_with_format}\n")
+
+    with complete_step("Installing grub boot loader…"):
+        # We don't setup the mountinfo bind mount with bwrap because we need to know the child process pid to
+        # be able to do the mount and we don't know the pid beforehand.
+        bwrap(["sh", "-c", f"mount --bind {mountinfo} /proc/$$/mountinfo && exec $0 \"$@\"",
+               setup,
+               "--directory", state.root / "efi" / prefix / "i386-pc",
+               *(["--verbose"] if ARG_DEBUG.get() else []),
+               state.staging / state.config.output_with_format],
+              options=["--bind", state.root / "usr", "/usr"])
+
+
 def install_base_trees(state: MkosiState) -> None:
     if not state.config.base_trees or state.config.overlay:
         return
@@ -1376,19 +1600,37 @@ def make_image(state: MkosiState, skip: Sequence[str] = [], split: bool = False)
             definitions.mkdir()
             bootloader = state.root / f"efi/EFI/BOOT/BOOT{state.config.architecture.to_efi()}.EFI"
 
-            add = (state.config.bootable == ConfigFeature.enabled or
+            # If grub for BIOS is installed, let's add a BIOS boot partition onto which we can install grub.
+            bios = (state.config.bootable != ConfigFeature.disabled and want_grub_bios(state))
+
+            if bios:
+                (definitions / "05-bios.conf").write_text(
+                    textwrap.dedent(
+                        f"""\
+                        [Partition]
+                        Type={Partition.GRUB_BOOT_PARTITION_UUID}
+                        SizeMinBytes=1M
+                        SizeMaxBytes=1M
+                        """
+                    )
+                )
+
+            esp = (state.config.bootable == ConfigFeature.enabled or
                   (state.config.bootable == ConfigFeature.auto and bootloader.exists()))
 
-            if add:
+            if esp or bios:
+                # Even if we're doing BIOS, let's still use the ESP to store the kernels, initrds and grub
+                # modules. We cant use UKIs so we have to put each kernel and initrd on the ESP twice, so
+                # let's make the ESP twice as big in that case.
                 (definitions / "00-esp.conf").write_text(
                     textwrap.dedent(
-                        """\
+                        f"""\
                         [Partition]
                         Type=esp
                         Format=vfat
                         CopyFiles=/efi:/
-                        SizeMinBytes=512M
-                        SizeMaxBytes=512M
+                        SizeMinBytes={"1G" if bios else "512M"}
+                        SizeMaxBytes={"1G" if bios else "512M"}
                         """
                     )
                 )
@@ -1502,6 +1744,9 @@ def build_image(args: MkosiArgs, config: MkosiConfig) -> None:
 
         partitions = make_image(state, skip=("esp", "xbootldr"))
         install_unified_kernel(state, partitions)
+        prepare_grub_bios(state, partitions)
+        partitions = make_image(state)
+        install_grub_bios(state, partitions)
         make_image(state, split=True)
 
         if state.config.output_format == OutputFormat.tar:
index 63b08df02cc37d04f8696418ea52081ee95f46e1..997734b4b1d5d74641e0967b3b8271c4abd23ff5 100644 (file)
@@ -121,6 +121,11 @@ class Bootloader(StrEnum):
     systemd_boot = enum.auto()
 
 
+class BiosBootloader(StrEnum):
+    none = enum.auto()
+    grub = enum.auto()
+
+
 def parse_boolean(s: str) -> bool:
     "Parse 1/true/yes/y/t/on as true and 0/false/no/n/f/off/None as false"
     s_l = s.lower()
@@ -695,6 +700,7 @@ class MkosiConfig:
 
     bootable: ConfigFeature
     bootloader: Bootloader
+    bios_bootloader: BiosBootloader
     initrds: list[Path]
     kernel_command_line: list[str]
     kernel_modules_include: list[str]
@@ -1195,7 +1201,16 @@ class MkosiConfigParser:
             parse=config_make_enum_parser(Bootloader),
             choices=Bootloader.values(),
             default=Bootloader.systemd_boot,
-            help="Specify which bootloader to use",
+            help="Specify which UEFI bootloader to use",
+        ),
+        MkosiConfigSetting(
+            dest="bios_bootloader",
+            metavar="BOOTLOADER",
+            section="Content",
+            parse=config_make_enum_parser(BiosBootloader),
+            choices=BiosBootloader.values(),
+            default=BiosBootloader.none,
+            help="Specify which BIOS bootloader to use",
         ),
         MkosiConfigSetting(
             dest="initrds",
@@ -2253,6 +2268,7 @@ Clean Package Manager Metadata: {yes_no_auto(config.clean_package_metadata)}
 
                       Bootable: {yes_no_auto(config.bootable)}
                     Bootloader: {config.bootloader}
+               BIOS Bootloader: {config.bios_bootloader}
                        Initrds: {line_join_list(config.initrds)}
            Kernel Command Line: {line_join_list(config.kernel_command_line)}
         Kernel Modules Include: {line_join_list(config.kernel_modules_include)}
index abca87ff9d9967e321f4c47e114aeb26087460a7..3b7f71cdbc1e287924034010ec34684c1746689a 100644 (file)
@@ -791,6 +791,25 @@ they should be specified with a boolean argument: either "1", "yes", or "true" t
   be generated for the latest installed kernel (the one with the highest
   version) which is installed to `EFI/BOOT/BOOTX64.EFI` in the ESP.
 
+`BiosBootloader=`, `--bios-bootloader=`
+
+: Takes one of `none` or `grub`. Defaults to `none`. If set to `none`,
+  no BIOS bootloader will be installed. If set to `grub`, grub is
+  installed as the BIOS boot loader if a bootable image is requested
+  with the `Bootable=` option. If no repart partition definition files
+  are configured, mkosi will add a grub BIOS boot partition and an EFI
+  system partition to the default partition definition files.
+
+: Note that this option is not mutually exclusive with `Bootloader=`. It
+  is possible to have an image that is both bootable on UEFI and BIOS by
+  configuring both `Bootloader=` and `BiosBootloader=`.
+
+: The grub BIOS boot partition should have UUID
+  `21686148-6449-6e6f-744e-656564454649` and should be at least 1MB.
+
+: Even if no EFI bootloader is installed, we still need an ESP for BIOS
+  boot as that's where we store the kernel, initrd and grub modules.
+
 `Initrds=`, `--initrd`
 
 : Use user-provided initrd(s). Takes a comma separated list of paths to