From 588b8c05ac49a1ba871804ecab4fcb7dd4836394 Mon Sep 17 00:00:00 2001 From: Florian Schroegendorfer Date: Sun, 9 Jun 2024 22:12:45 +0200 Subject: [PATCH] added option to create a fallback boot entry --- ekernel.py | 188 +++++++++++++++++++++++++++++----------- tests/data/kernel.py | 30 ++++--- tests/test_configure.py | 2 +- tests/test_install.py | 56 ++++++++++-- tests/test_kernel.py | 2 - tests/test_update.py | 4 +- 6 files changed, 207 insertions(+), 75 deletions(-) diff --git a/ekernel.py b/ekernel.py index d8a720a..f662fa7 100644 --- a/ekernel.py +++ b/ekernel.py @@ -4,6 +4,7 @@ import functools import io import os import pathlib +import platform import re import shutil import subprocess @@ -44,12 +45,6 @@ class Kernel: # module directory modules = pathlib.Path("/lib/modules") - # EFI system partition - esp = pathlib.Path("/boot") - - # EFI bootloader (stub kernel) - boot = esp / "EFI/Gentoo/bootx64.efi" - def __init__ (self, src): """Construct a Kernel based on a given source path.""" self.src = pathlib.Path(src) @@ -61,7 +56,7 @@ class Kernel: raise ValueError(f"illegal source: {src}") from e self.config = self.src / ".config" self.bzImage = self.src / "arch/x86_64/boot/bzImage" - self.bkp = self.boot.parent / f"gentoo-{self.version.base_version}.efi" + self.bkp = efi.boot.parent / f"gentoo-{self.version.base_version}.efi" self.modules = self.modules / f"{self.version.base_version}-gentoo" def __eq__ (self, other): @@ -123,62 +118,91 @@ def cli (f): def efi (f): """Decorator locating and mounting the ESP through efivars.""" + efi.skip = False + # system partition + efi.esp = pathlib.Path("/boot") + # bootloader (stub kernel) + efi.boot = efi.esp / "EFI/Gentoo/bootx64.efi" + # backup entry data + efi.bkp = {} + # analyze boot entries and ensure access to the currently running image @functools.wraps(f) def locator (*args, **kwargs): + # skip detection + if efi.skip: + return f(*args, **kwargs) + efi.skip = True + # get boot entries + mgr = subprocess.run( + ["efibootmgr"], + capture_output=True, + check=True + ) + entries = mgr.stdout.decode().splitlines() + bootnum = "NaN" + for l in entries: + if l.startswith("BootCurrent"): + bootnum = l.split()[1] + break + # find currently running boot entry / loader + def parse (entry): + # label + i = l.find(" ") + 1 + j = l.find("\t", i) + label = l[i:j] + # loader + i = l.find("File", j) + if i < 0: + raise RuntimeError(f"error locating bootloader:\n{l}") + i += 6 + j = l.find(")", i) + loader = pathlib.Path(l[i:j].replace("\\", "/")) + return label, loader + for l in entries: + if l.startswith(f"Boot{bootnum}"): + label, loader = parse(l) + efi.label = label + efi.bkp["label"] = f"{label} (fallback)" + break + # find bootnum of backup entry + for l in entries: + if efi.bkp["label"] in l: + efi.bkp["bootnum"] = l[4:8] + break + # mount esp mounted = False - # ensure access to the currently running EFI bootloader / stub kernel - if not Kernel.boot.exists(): - # find current bootloader - mgr = subprocess.run( - ["efibootmgr"], - capture_output=True, - check=True - ) - lines = iter(mgr.stdout.decode().splitlines()) - bootnum = "NaN" - for l in lines: - if l.startswith("BootCurrent"): - bootnum = l.split()[1] - break - for l in lines: - if l.startswith(f"Boot{bootnum}"): - i = l.find("File") - if i < 0: - raise RuntimeError(f"error locating bootloader:\n{l}") - i += 6 - j = l.find(")", i) - loader = pathlib.Path(l[i:j].replace("\\", "/")) - break + if not efi.boot.exists(): # find mountpoint for l in pathlib.Path("/etc/fstab").read_text().splitlines(): if not l.startswith("#"): for p in ["/boot", "/efi"]: if p in l: - Kernel.esp = pathlib.Path(p) - Kernel.boot = Kernel.esp / loader + efi.esp = pathlib.Path(p) + efi.boot = efi.esp / loader break else: continue break - # mount esp - if not Kernel.boot.exists(): - try: - subprocess.run( - ["mount", str(Kernel.esp)], - capture_output=True, - check=True - ) - mounted = True - except subprocess.CalledProcessError as e: - msg = e.stderr.decode().strip() - if f"already mounted on {Kernel.esp}" not in msg: - raise RuntimeError(e.stderr.decode().splitlines()[0]) - assert Kernel.boot.exists() + else: + raise RuntimeError("error finding ESP mountpoint") + try: + subprocess.run( + ["mount", str(efi.esp)], + capture_output=True, + check=True + ) + mounted = True + except subprocess.CalledProcessError as e: + msg = e.stderr.decode().strip() + if f"already mounted on {efi.esp}" not in msg: + raise RuntimeError(e.stderr.decode().splitlines()[0]) + assert efi.boot.exists() try: return f(*args, **kwargs) finally: + efi.skip = False # umount esp if mounted: - subprocess.run(["umount", str(Kernel.esp)], check=True) + subprocess.run(["umount", str(efi.esp)], check=True) return locator @cli @@ -267,7 +291,7 @@ def configure (argv): try: oldconfig = Kernel.current().config except FileNotFoundError: - oldconfig = Kernel.esp / "FILENOTFOUND" + oldconfig = efi.esp / "FILENOTFOUND" # change to source directory os.chdir(kernel.src) @@ -391,6 +415,9 @@ def install (argv): Command Line Arguments ---------------------- + ``-b`` + create fallback boot entry (default: false) + ``-s `` kernel source directory (default: latest) @@ -417,6 +444,12 @@ def install (argv): description="Install a kernel.", formatter_class=argparse.RawDescriptionHelpFormatter ) + parser.add_argument( + "-b", + dest="bkp", + action="store_true", + help="create fallback boot entry" + ) parser.add_argument( "-s", metavar="", @@ -439,6 +472,50 @@ def install (argv): if not kernel.bzImage.exists(): raise FileNotFoundError(f"missing bzImage {kernel.bzImage}") + # create backup boot entry + if args.bkp: + # path to backup image + bkp = None + # find the currently running kernel's backup image + boot_bytes = efi.boot.read_bytes() + for f in efi.boot.parent.glob("gentoo*.efi"): + if f.read_bytes() == boot_bytes: + bkp = f + break + # not found + else: + name = f"gentoo-{version(platform.release()).base_version}.efi" + bkp = efi.boot.parent / name + shutil.copy(efi.boot, bkp) + # get ESP disk and partition number + dev = subprocess.run( + ["findmnt", "-rno", "SOURCE", str(efi.esp)], + capture_output=True, + check=True + ) + disk, part = filter( + None, + re.search(r"([/a-z]+)(\d+)", dev.stdout.decode()).groups() + ) + # remove previous entry + if "bootnum" in efi.bkp: + subprocess.run([ + "efibootmgr", + "-q", + "-b", efi.bkp["bootnum"], + "-B" + ], check=True) + # create entry + subprocess.run([ + "efibootmgr", + "-q", + "-c", + "-d", disk, + "-p", part, + "-L", efi.bkp["label"], + "-l", str(bkp) + ], check=True) + # update symlink to the new source directory out.einfo( "updating symlink " @@ -450,8 +527,8 @@ def install (argv): ) # copy boot image - out.einfo(f"creating boot image {out.hilite(kernel.boot)}") - shutil.copy(kernel.bzImage, kernel.boot) + out.einfo(f"creating boot image {out.hilite(efi.boot)}") + shutil.copy(kernel.bzImage, efi.boot) # create backup out.einfo(f"creating backup image {out.hilite(kernel.bkp)}") @@ -530,7 +607,7 @@ def clean (argv): if leftovers: out.einfo("the following kernels will be removed:") for k in leftovers: - print(f" {colorize('BAD', '✗')} {k.src.name}") + out.print(f" {colorize('BAD', '✗')} {k.src.name}") else: out.einfo("nothing to see here") return @@ -793,6 +870,12 @@ def update (argv): type=pathlib.Path, help="kernel source directory (default: latest)" ) + parser.add_argument( + "-b", + dest="bkp", + action="store_true", + help="create fallback boot entry" + ) parser.add_argument( "-k", metavar="", @@ -816,12 +899,13 @@ def update (argv): args = parser.parse_args(argv) args.jobs = ["-j", str(args.jobs)] if args.jobs else [] args.src = ["-s", str(args.src)] if args.src else [] + args.bkp = ["-b"] if args.bkp else [] args.keep = ["-k", str(args.keep)] if args.keep is not None else [] args.msg = ["-m", args.msg] if args.msg else [] args.quiet = ["-q"] if args.quiet else [] configure(args.quiet + args.src) build(args.quiet + args.jobs + args.src) - install(args.quiet + args.src) + install(args.quiet + args.bkp + args.src) clean(args.quiet + args.keep) commit(args.quiet + args.msg) diff --git a/tests/data/kernel.py b/tests/data/kernel.py index ec0ffaf..dea96f2 100644 --- a/tests/data/kernel.py +++ b/tests/data/kernel.py @@ -5,7 +5,7 @@ import shutil import subprocess import tempfile -from ekernel import Kernel +import ekernel # create temporary directory tmpdir = tempfile.TemporaryDirectory() @@ -74,18 +74,20 @@ def efi (f): @functools.wraps(f) def runner (t, *args, **kwargs): if args[0][0] == "efibootmgr": - boot.touch() return subprocess.CompletedProcess("", 0, "BootCurrent: 0001\n" "Timeout: 1 seconds\n" "BootOrder: 0001,0000\n" - "Boot0000* Windows HD()/File()\n" - "Boot0001* Gentoo HD()/File(\\EFI\\gentoo\\bootx64.efi)\n" + "Boot0000* Windows\tHD()/File()\n" + "Boot0001* Gentoo\tHD()/File(\\EFI\\Gentoo\\bootx64.efi)\n" + "Boot0002* Gentoo (ignore)\tHD()/File()\n" + "Boot0003* Gentoo (fallback)\tHD()/File()\n" .encode() ) elif args[0][0] == "mount": - Kernel.esp = esp - Kernel.boot = boot + ekernel.efi.esp = esp + ekernel.efi.boot = boot + boot.write_bytes(str(kernels[1].bkp).encode()) return f(t, *args, **kwargs) return runner @@ -96,11 +98,13 @@ def setup (): shutil.rmtree(p) # change Kernel paths - Kernel.src = src - Kernel.linux = linux - Kernel.modules = modules - Kernel.esp = esp - Kernel.boot = boot + ekernel.Kernel.src = src + ekernel.Kernel.linux = linux + ekernel.Kernel.modules = modules + + # change EFI paths + ekernel.efi.esp = esp + ekernel.efi.boot = boot # create EFI system partition boot.parent.mkdir(parents=True) @@ -108,7 +112,7 @@ def setup (): # create Kernels for s in sources: s.mkdir(parents=True) global kernels - kernels = [ Kernel(s) for s in sources ] + kernels = [ ekernel.Kernel(s) for s in sources ] # create config and build files, expect for the latest for k in kernels: @@ -119,7 +123,7 @@ def setup (): else: k.config.touch() k.bzImage.touch() - k.bkp.touch() + k.bkp.write_bytes(str(k.bkp).encode()) k.modules.mkdir(parents=True) # symlink to old source directory diff --git a/tests/test_configure.py b/tests/test_configure.py index d40ad47..2aec203 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -26,7 +26,7 @@ class Tests (unittest.TestCase): if args[0][0] == "make": if args[0][1] == "listnewconfig": make = subprocess.CompletedProcess("", 0) - make.stdout = str.encode(data.newoptions) + make.stdout = data.newoptions.encode() return make elif args[0][1] == "menuconfig": self.kernel.config.touch() diff --git a/tests/test_install.py b/tests/test_install.py index 763efeb..199273d 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1,3 +1,4 @@ +import platform import subprocess import sys import unittest @@ -18,32 +19,66 @@ class Tests (unittest.TestCase): data.setup() self.kernel = Kernel.latest() self.kernel.bzImage.touch() + self.current = Kernel.current() # start interceptor @data.efi def run (tracer, *args, **kwargs): - if args[0][0] == "eselect": + if args[0][0] == "findmnt": + return subprocess.CompletedProcess("", 0, b"/dev/sda1") + elif args[0][0] == "eselect": data.linux.unlink() data.linux.symlink_to(self.kernel.src.name) + def release (tracer, *args, **kwargs): + return f"{self.current.version.base_version}-gentoo" self.interceptor = Interceptor() self.interceptor.add(subprocess.run, call=run) + self.interceptor.add(platform.release, call=release) self.interceptor.start() def tearDown (self): # stop interceptor self.interceptor.stop() - def check_install (self): + def check_install (self, backup=False): trace_it = iter(self.interceptor.trace) # efibootmgr tracer, (args, kwargs) = next(trace_it) self.assertEqual(tracer.name, "subprocess.run") self.assertEqual(args, (["efibootmgr"],)) self.assertEqual(kwargs, {"capture_output": True, "check": True}) - # mount /boot + # mount tracer, (args, kwargs) = next(trace_it) self.assertEqual(tracer.name, "subprocess.run") self.assertEqual(args, (["mount", "/boot"],)) self.assertEqual(kwargs, {"capture_output": True, "check": True}) + if backup: + if data.boot.read_bytes() == b"missing image": + # platform.release + tracer, (args, kwargs) = next(trace_it) + self.assertEqual(tracer.name, "platform.release") + # findmnt -rno SOURCE + tracer, (args, kwargs) = next(trace_it) + self.assertEqual(tracer.name, "subprocess.run") + self.assertEqual(args, (["findmnt", "-rno", "SOURCE", "/tmp"],)) + self.assertEqual(kwargs, {"capture_output": True, "check": True}) + # efibootmgr -b 0003 -B + tracer, (args, kwargs) = next(trace_it) + self.assertEqual(tracer.name, "subprocess.run") + self.assertEqual(args, (["efibootmgr", "-q", "-b", "0003", "-B"],)) + self.assertEqual(kwargs, {"check": True}) + # efibootmgr -c -d -p -L