added option to create a fallback boot entry

This commit is contained in:
Florian Schroegendorfer 2024-06-09 22:12:45 +02:00
parent d622c8064c
commit 588b8c05ac
Signed by: root
GPG Key ID: 17625E28D4D6E339
6 changed files with 207 additions and 75 deletions

View File

@ -4,6 +4,7 @@ import functools
import io import io
import os import os
import pathlib import pathlib
import platform
import re import re
import shutil import shutil
import subprocess import subprocess
@ -44,12 +45,6 @@ class Kernel:
# module directory # module directory
modules = pathlib.Path("/lib/modules") 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): def __init__ (self, src):
"""Construct a Kernel based on a given source path.""" """Construct a Kernel based on a given source path."""
self.src = pathlib.Path(src) self.src = pathlib.Path(src)
@ -61,7 +56,7 @@ class Kernel:
raise ValueError(f"illegal source: {src}") from e raise ValueError(f"illegal source: {src}") from e
self.config = self.src / ".config" self.config = self.src / ".config"
self.bzImage = self.src / "arch/x86_64/boot/bzImage" 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" self.modules = self.modules / f"{self.version.base_version}-gentoo"
def __eq__ (self, other): def __eq__ (self, other):
@ -123,62 +118,91 @@ def cli (f):
def efi (f): def efi (f):
"""Decorator locating and mounting the ESP through efivars.""" """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) @functools.wraps(f)
def locator (*args, **kwargs): def locator (*args, **kwargs):
mounted = False # skip detection
# ensure access to the currently running EFI bootloader / stub kernel if efi.skip:
if not Kernel.boot.exists(): return f(*args, **kwargs)
# find current bootloader efi.skip = True
# get boot entries
mgr = subprocess.run( mgr = subprocess.run(
["efibootmgr"], ["efibootmgr"],
capture_output=True, capture_output=True,
check=True check=True
) )
lines = iter(mgr.stdout.decode().splitlines()) entries = mgr.stdout.decode().splitlines()
bootnum = "NaN" bootnum = "NaN"
for l in lines: for l in entries:
if l.startswith("BootCurrent"): if l.startswith("BootCurrent"):
bootnum = l.split()[1] bootnum = l.split()[1]
break break
for l in lines: # find currently running boot entry / loader
if l.startswith(f"Boot{bootnum}"): def parse (entry):
i = l.find("File") # label
i = l.find(" ") + 1
j = l.find("\t", i)
label = l[i:j]
# loader
i = l.find("File", j)
if i < 0: if i < 0:
raise RuntimeError(f"error locating bootloader:\n{l}") raise RuntimeError(f"error locating bootloader:\n{l}")
i += 6 i += 6
j = l.find(")", i) j = l.find(")", i)
loader = pathlib.Path(l[i:j].replace("\\", "/")) 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 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
if not efi.boot.exists():
# find mountpoint # find mountpoint
for l in pathlib.Path("/etc/fstab").read_text().splitlines(): for l in pathlib.Path("/etc/fstab").read_text().splitlines():
if not l.startswith("#"): if not l.startswith("#"):
for p in ["/boot", "/efi"]: for p in ["/boot", "/efi"]:
if p in l: if p in l:
Kernel.esp = pathlib.Path(p) efi.esp = pathlib.Path(p)
Kernel.boot = Kernel.esp / loader efi.boot = efi.esp / loader
break break
else: continue else: continue
break break
# mount esp else:
if not Kernel.boot.exists(): raise RuntimeError("error finding ESP mountpoint")
try: try:
subprocess.run( subprocess.run(
["mount", str(Kernel.esp)], ["mount", str(efi.esp)],
capture_output=True, capture_output=True,
check=True check=True
) )
mounted = True mounted = True
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
msg = e.stderr.decode().strip() msg = e.stderr.decode().strip()
if f"already mounted on {Kernel.esp}" not in msg: if f"already mounted on {efi.esp}" not in msg:
raise RuntimeError(e.stderr.decode().splitlines()[0]) raise RuntimeError(e.stderr.decode().splitlines()[0])
assert Kernel.boot.exists() assert efi.boot.exists()
try: try:
return f(*args, **kwargs) return f(*args, **kwargs)
finally: finally:
efi.skip = False
# umount esp # umount esp
if mounted: if mounted:
subprocess.run(["umount", str(Kernel.esp)], check=True) subprocess.run(["umount", str(efi.esp)], check=True)
return locator return locator
@cli @cli
@ -267,7 +291,7 @@ def configure (argv):
try: try:
oldconfig = Kernel.current().config oldconfig = Kernel.current().config
except FileNotFoundError: except FileNotFoundError:
oldconfig = Kernel.esp / "FILENOTFOUND" oldconfig = efi.esp / "FILENOTFOUND"
# change to source directory # change to source directory
os.chdir(kernel.src) os.chdir(kernel.src)
@ -391,6 +415,9 @@ def install (argv):
Command Line Arguments Command Line Arguments
---------------------- ----------------------
``-b``
create fallback boot entry (default: false)
``-s <src>`` ``-s <src>``
kernel source directory (default: latest) kernel source directory (default: latest)
@ -417,6 +444,12 @@ def install (argv):
description="Install a kernel.", description="Install a kernel.",
formatter_class=argparse.RawDescriptionHelpFormatter formatter_class=argparse.RawDescriptionHelpFormatter
) )
parser.add_argument(
"-b",
dest="bkp",
action="store_true",
help="create fallback boot entry"
)
parser.add_argument( parser.add_argument(
"-s", "-s",
metavar="<src>", metavar="<src>",
@ -439,6 +472,50 @@ def install (argv):
if not kernel.bzImage.exists(): if not kernel.bzImage.exists():
raise FileNotFoundError(f"missing bzImage {kernel.bzImage}") 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 # update symlink to the new source directory
out.einfo( out.einfo(
"updating symlink " "updating symlink "
@ -450,8 +527,8 @@ def install (argv):
) )
# copy boot image # copy boot image
out.einfo(f"creating boot image {out.hilite(kernel.boot)}") out.einfo(f"creating boot image {out.hilite(efi.boot)}")
shutil.copy(kernel.bzImage, kernel.boot) shutil.copy(kernel.bzImage, efi.boot)
# create backup # create backup
out.einfo(f"creating backup image {out.hilite(kernel.bkp)}") out.einfo(f"creating backup image {out.hilite(kernel.bkp)}")
@ -530,7 +607,7 @@ def clean (argv):
if leftovers: if leftovers:
out.einfo("the following kernels will be removed:") out.einfo("the following kernels will be removed:")
for k in leftovers: for k in leftovers:
print(f" {colorize('BAD', '')} {k.src.name}") out.print(f" {colorize('BAD', '')} {k.src.name}")
else: else:
out.einfo("nothing to see here") out.einfo("nothing to see here")
return return
@ -793,6 +870,12 @@ def update (argv):
type=pathlib.Path, type=pathlib.Path,
help="kernel source directory (default: latest)" help="kernel source directory (default: latest)"
) )
parser.add_argument(
"-b",
dest="bkp",
action="store_true",
help="create fallback boot entry"
)
parser.add_argument( parser.add_argument(
"-k", "-k",
metavar="<keep>", metavar="<keep>",
@ -816,12 +899,13 @@ def update (argv):
args = parser.parse_args(argv) args = parser.parse_args(argv)
args.jobs = ["-j", str(args.jobs)] if args.jobs else [] args.jobs = ["-j", str(args.jobs)] if args.jobs else []
args.src = ["-s", str(args.src)] if args.src 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.keep = ["-k", str(args.keep)] if args.keep is not None else []
args.msg = ["-m", args.msg] if args.msg else [] args.msg = ["-m", args.msg] if args.msg else []
args.quiet = ["-q"] if args.quiet else [] args.quiet = ["-q"] if args.quiet else []
configure(args.quiet + args.src) configure(args.quiet + args.src)
build(args.quiet + args.jobs + 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) clean(args.quiet + args.keep)
commit(args.quiet + args.msg) commit(args.quiet + args.msg)

View File

@ -5,7 +5,7 @@ import shutil
import subprocess import subprocess
import tempfile import tempfile
from ekernel import Kernel import ekernel
# create temporary directory # create temporary directory
tmpdir = tempfile.TemporaryDirectory() tmpdir = tempfile.TemporaryDirectory()
@ -74,18 +74,20 @@ def efi (f):
@functools.wraps(f) @functools.wraps(f)
def runner (t, *args, **kwargs): def runner (t, *args, **kwargs):
if args[0][0] == "efibootmgr": if args[0][0] == "efibootmgr":
boot.touch()
return subprocess.CompletedProcess("", 0, return subprocess.CompletedProcess("", 0,
"BootCurrent: 0001\n" "BootCurrent: 0001\n"
"Timeout: 1 seconds\n" "Timeout: 1 seconds\n"
"BootOrder: 0001,0000\n" "BootOrder: 0001,0000\n"
"Boot0000* Windows HD()/File()\n" "Boot0000* Windows\tHD()/File()\n"
"Boot0001* Gentoo HD()/File(\\EFI\\gentoo\\bootx64.efi)\n" "Boot0001* Gentoo\tHD()/File(\\EFI\\Gentoo\\bootx64.efi)\n"
"Boot0002* Gentoo (ignore)\tHD()/File()\n"
"Boot0003* Gentoo (fallback)\tHD()/File()\n"
.encode() .encode()
) )
elif args[0][0] == "mount": elif args[0][0] == "mount":
Kernel.esp = esp ekernel.efi.esp = esp
Kernel.boot = boot ekernel.efi.boot = boot
boot.write_bytes(str(kernels[1].bkp).encode())
return f(t, *args, **kwargs) return f(t, *args, **kwargs)
return runner return runner
@ -96,11 +98,13 @@ def setup ():
shutil.rmtree(p) shutil.rmtree(p)
# change Kernel paths # change Kernel paths
Kernel.src = src ekernel.Kernel.src = src
Kernel.linux = linux ekernel.Kernel.linux = linux
Kernel.modules = modules ekernel.Kernel.modules = modules
Kernel.esp = esp
Kernel.boot = boot # change EFI paths
ekernel.efi.esp = esp
ekernel.efi.boot = boot
# create EFI system partition # create EFI system partition
boot.parent.mkdir(parents=True) boot.parent.mkdir(parents=True)
@ -108,7 +112,7 @@ def setup ():
# create Kernels # create Kernels
for s in sources: s.mkdir(parents=True) for s in sources: s.mkdir(parents=True)
global kernels 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 # create config and build files, expect for the latest
for k in kernels: for k in kernels:
@ -119,7 +123,7 @@ def setup ():
else: else:
k.config.touch() k.config.touch()
k.bzImage.touch() k.bzImage.touch()
k.bkp.touch() k.bkp.write_bytes(str(k.bkp).encode())
k.modules.mkdir(parents=True) k.modules.mkdir(parents=True)
# symlink to old source directory # symlink to old source directory

View File

@ -26,7 +26,7 @@ class Tests (unittest.TestCase):
if args[0][0] == "make": if args[0][0] == "make":
if args[0][1] == "listnewconfig": if args[0][1] == "listnewconfig":
make = subprocess.CompletedProcess("", 0) make = subprocess.CompletedProcess("", 0)
make.stdout = str.encode(data.newoptions) make.stdout = data.newoptions.encode()
return make return make
elif args[0][1] == "menuconfig": elif args[0][1] == "menuconfig":
self.kernel.config.touch() self.kernel.config.touch()

View File

@ -1,3 +1,4 @@
import platform
import subprocess import subprocess
import sys import sys
import unittest import unittest
@ -18,32 +19,66 @@ class Tests (unittest.TestCase):
data.setup() data.setup()
self.kernel = Kernel.latest() self.kernel = Kernel.latest()
self.kernel.bzImage.touch() self.kernel.bzImage.touch()
self.current = Kernel.current()
# start interceptor # start interceptor
@data.efi @data.efi
def run (tracer, *args, **kwargs): 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.unlink()
data.linux.symlink_to(self.kernel.src.name) 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 = Interceptor()
self.interceptor.add(subprocess.run, call=run) self.interceptor.add(subprocess.run, call=run)
self.interceptor.add(platform.release, call=release)
self.interceptor.start() self.interceptor.start()
def tearDown (self): def tearDown (self):
# stop interceptor # stop interceptor
self.interceptor.stop() self.interceptor.stop()
def check_install (self): def check_install (self, backup=False):
trace_it = iter(self.interceptor.trace) trace_it = iter(self.interceptor.trace)
# efibootmgr # efibootmgr
tracer, (args, kwargs) = next(trace_it) tracer, (args, kwargs) = next(trace_it)
self.assertEqual(tracer.name, "subprocess.run") self.assertEqual(tracer.name, "subprocess.run")
self.assertEqual(args, (["efibootmgr"],)) self.assertEqual(args, (["efibootmgr"],))
self.assertEqual(kwargs, {"capture_output": True, "check": True}) self.assertEqual(kwargs, {"capture_output": True, "check": True})
# mount /boot # mount <boot>
tracer, (args, kwargs) = next(trace_it) tracer, (args, kwargs) = next(trace_it)
self.assertEqual(tracer.name, "subprocess.run") self.assertEqual(tracer.name, "subprocess.run")
self.assertEqual(args, (["mount", "/boot"],)) self.assertEqual(args, (["mount", "/boot"],))
self.assertEqual(kwargs, {"capture_output": True, "check": True}) 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 <boot>
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 <disk> -p <part> -L <label> -l <loader>
tracer, (args, kwargs) = next(trace_it)
self.assertEqual(tracer.name, "subprocess.run")
self.assertEqual(args, ([
"efibootmgr",
"-q",
"-c",
"-d", "/dev/sda",
"-p", "1",
"-L", "Gentoo (fallback)",
"-l", str(self.current.bkp)
],))
self.assertEqual(kwargs, {"check": True})
# eselect kernel set <name> # eselect kernel set <name>
tracer, (args, kwargs) = next(trace_it) tracer, (args, kwargs) = next(trace_it)
self.assertEqual(tracer.name, "subprocess.run") self.assertEqual(tracer.name, "subprocess.run")
@ -58,13 +93,13 @@ class Tests (unittest.TestCase):
self.assertEqual(tracer.name, "subprocess.run") self.assertEqual(tracer.name, "subprocess.run")
self.assertEqual(args, (["emerge", "@module-rebuild"],)) self.assertEqual(args, (["emerge", "@module-rebuild"],))
self.assertEqual(kwargs, {"check": True}) self.assertEqual(kwargs, {"check": True})
# umount /boot # umount <boot>
tracer, (args, kwargs) = next(trace_it) tracer, (args, kwargs) = next(trace_it)
self.assertEqual(tracer.name, "subprocess.run") self.assertEqual(tracer.name, "subprocess.run")
self.assertEqual(args, (["umount", "/tmp"],)) self.assertEqual(args, (["umount", "/tmp"],))
self.assertEqual(kwargs, {"check": True}) self.assertEqual(kwargs, {"check": True})
# check generated files # check generated files
self.assertTrue(self.kernel.boot.exists()) self.assertTrue(ekernel.efi.boot.exists())
self.assertTrue(self.kernel.bkp.exists()) self.assertTrue(self.kernel.bkp.exists())
def test_install (self): def test_install (self):
@ -76,6 +111,17 @@ class Tests (unittest.TestCase):
self.assertEqual(run("-q", "-s", str(data.current)), 0) self.assertEqual(run("-q", "-s", str(data.current)), 0)
self.check_install() self.check_install()
def test_install_backup (self):
self.assertEqual(run("-q", "-b"), 0)
self.check_install(backup=True)
def test_install_backup_missing_image (self):
self.current.bkp.unlink()
self.kernel.bzImage.write_bytes(b"missing image")
self.assertEqual(run("-q", "-b"), 0)
self.check_install(backup=True)
self.assertTrue(self.current.bkp.exists())
@capture_stderr @capture_stderr
def test_install_missing_bzImage (self): def test_install_missing_bzImage (self):
self.kernel.bzImage.unlink() self.kernel.bzImage.unlink()

View File

@ -28,8 +28,6 @@ class Tests (unittest.TestCase):
def test_paths (self): def test_paths (self):
self.assertEqual(ekernel.Kernel.src, data.src) self.assertEqual(ekernel.Kernel.src, data.src)
self.assertEqual(ekernel.Kernel.linux, data.linux) self.assertEqual(ekernel.Kernel.linux, data.linux)
self.assertEqual(ekernel.Kernel.esp, data.esp)
self.assertEqual(ekernel.Kernel.boot, data.boot)
self.assertEqual(ekernel.Kernel.modules, data.modules) self.assertEqual(ekernel.Kernel.modules, data.modules)
def test_version (self): def test_version (self):

View File

@ -32,7 +32,7 @@ class Tests (unittest.TestCase):
if args[0][0] == "make": if args[0][0] == "make":
if args[0][1] == "listnewconfig": if args[0][1] == "listnewconfig":
make = subprocess.CompletedProcess("", 0) make = subprocess.CompletedProcess("", 0)
make.stdout = str.encode(data.newoptions) make.stdout = data.newoptions.encode()
return make return make
elif args[0][1] == "oldconfig": elif args[0][1] == "oldconfig":
self.latest.config.write_text(data.newconfig) self.latest.config.write_text(data.newconfig)
@ -53,7 +53,7 @@ class Tests (unittest.TestCase):
self.assertTrue(self.oldconfig.exists()) self.assertTrue(self.oldconfig.exists())
self.assertTrue(self.latest.config.exists()) self.assertTrue(self.latest.config.exists())
# install # install
self.assertTrue(self.latest.boot.exists()) self.assertTrue(ekernel.efi.boot.exists())
self.assertTrue(self.latest.bkp.exists()) self.assertTrue(self.latest.bkp.exists())
# clean # clean
for k in data.kernels[2:]: for k in data.kernels[2:]: