This commit is contained in:
Florian Schroegendorfer 2024-06-03 18:05:29 +02:00
commit c59be062b1
Signed by: root
GPG Key ID: 17625E28D4D6E339
18 changed files with 2212 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.swp

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Florian Schrögendorfer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

61
Makefile Normal file
View File

@ -0,0 +1,61 @@
# python executable
PYTHON = python3 -B
# virtual environment path
VENV = venv
# command to activate the virtual environment
ACTIVATE = . $(VENV)/bin/activate
#
# testing
#
TESTS =
.PHONY: test
test: export PYTHONDONTWRITEBYTECODE = 1
test:
$(PYTHON) -m unittest -f $(TESTS)
#
# building
#
build: ekernel.py pyproject.toml setup.py
$(PYTHON) -m build --sdist
#
# code quality
#
FLAKE8_OPTS = --ignore E123,E124,E128,E201,E202,E211,E302,E306,E701
.PHONY: lint
lint: venv
@$(ACTIVATE) && flake8 $(FLAKE8_OPTS) lib cli tests
#
# initialize environment
#
.PHONY: init
init: requirements
# disable pip's cache (under ~/.cache/pip)
export PIP_NO_CACHE_DIR ?= true
# install dependencies
.PHONY: requirements
requirements: $(VENV)
$(ACTIVATE) && pip install -U -r requirements.txt
# create virtual environment
$(VENV):
$(PYTHON) -m venv $(VENV)
$(ACTIVATE) && pip install --upgrade pip wheel
# remove virtual environment
.PHONY: clean
clean:
rm -rf $(VENV)

124
README.md Normal file
View File

@ -0,0 +1,124 @@
# ekernel - Custom Gentoo EFI Stub Kernel Updater
Automate custom gentoo kernel update tasks.
The update process can roughly be divided into the usual three steps:
1) **configure** - copy and update the previous `.config`
2) **build** - compile the new kernel and install modules
3) **install** - copy the new EFI stub kernel image to the EFI system partition
4) **commit** - commit the new config with a detailed commit message
5) **clean** - remove unused kernel source directories, modules and boot images
This tool aims to minimize the effort by automating the previous steps, while
ensuring maximum flexibility w.r.t. configuration changes.
## Tasks
The following variables will be used throughout this description:
`old` - previous kernel's source directory
`new` - new kernel's source directory
`esp` - directory containing the live kernel image on the EFI system partition (`/boot/EFI/Gentoo/bootx64.efi`)
`jobs` - number of parallel make jobs given by ``-j`` (default: 64)
`version` - new kernel's version string
### <p>configure<span style="float:right">`ekernel-configure`</span></p>
Runs `make menuconfig` if the current config file is missing, or no other kernel is installed
Otherwise, copy the current config file to the new source directory if it doesn't exist (prevent accidental overwrite).
```sh
cp -n ${old}/.config ${new}
```
Get newly added config options and store the result in `${new}/.newoptions`.
```sh
cd ${new}
make listnewconfig > ${new}/.newoptions
```
If ``-l`` was selected: print newly added config options and exit.
```sh
cat ${new}/.newoptions
exit
```
Interactively update the previous config and exit if aborted.
```sh
cd ${new}
make oldconfig || exit
```
### <p>build<span style="float:right">`ekernel-build`</span></p>
Build and install modules, using the given number of jobs.
```sh
make -j ${jobs} && make modules_install
```
### <p>install<span style="float:right">`ekernel-install`</span></p>
Update symlink ``/usr/src/linux`` to the new source directory.
```sh
eselect kernel set $(basename ${new})
```
Install the EFI stub kernel image (and a backup copy to revert to in case something breaks after a subsequent kernel update).
```sh
mount /boot
cp ${new}/arch/x86_64/boot/bzImage ${esp}/bootx64.efi
cp ${new}/arch/x86_64/boot/bzImage ${esp}/gentoo-${version}.efi
```
Rebuild external modules.
```sh
emerge @module-rebuild
```
### <p>commit<span style="float:right">`ekernel-commit`</span></p>
Commit the new kernel config with a detailed commit message.
```sh
git add -f /usr/src/linux/.config
git commit -S -m "${msg}"
```
The message will not only contain the version change, but also details about the newly added or removed options.
### <p>clean<span style="float:right">`ekernel-clean`</span></p>
Remove unused kernel source directories, modules and boot images.
```sh
emerge --depclean sys-kernel/gentoo-sources
rm -rf $(find /usr/src/ -maxdepth 1 -name "linux-*" | sed -e '/${old}/d' -e '/${new}/d')
rm -rf $(ls -1 /lib/modules/ | sed -e '/${old}/d' -e '/${new}/d')
rm -rf $(ls -1 ${esp} | sed -e '/${old}/d' -e '/${new}/d' -e '/bootx64/d')
```
The default is to keep the previous kernel version in case something goes
horribly wrong.
## Installation
TODO: add ebuild `app-admin/ekernel`
## Requirements
* [`>=dev-lang/python-3.10`](https://packages.gentoo.org/packages/dev-lang/python)
* [`dev-python/packaging`](https://packages.gentoo.org/packages/dev-python/packaging)
* [`sys-apps/portage`](https://packages.gentoo.org/packages/sys-apps/portage)

786
ekernel.py Normal file
View File

@ -0,0 +1,786 @@
import argparse
import difflib
import functools
import io
import os
import pathlib
import re
import shutil
import subprocess
import sys
from packaging.version import Version
from portage import output, colorize
__version__ = "0.1"
# number of parallel build jobs
jobs = "4"
# gentoo's fancy terminal output functions
out = output.EOutput()
out.print = lambda s: print(s) if not out.quiet else None
out.hilite = lambda s: colorize("HILITE", str(s))
# disable colorization for pipes and redirects
if not sys.stdout.isatty():
output.havecolor = 0
def version (string: str):
"""Extract the version from a given string."""
return Version("".join(filter(
None,
re.search(r"(\d+.\d+.\d+)-gentoo(-\w+\d+)?", string).groups()
)))
class Kernel:
# kernel source directory
src = pathlib.Path("/usr/src")
# kernel source symlink
linux = src / "linux"
# EFI system partition
esp = pathlib.Path("/boot/EFI/Gentoo")
# boot image
bootx64 = esp / "bootx64.efi"
# module directory
modules = pathlib.Path("/lib/modules")
def __init__ (self, src):
"""Construct a Kernel based on a given source path."""
self.src = pathlib.Path(src)
if not self.src.exists():
raise ValueError(f"missing source: {src}")
try:
self.version = version(self.src.name)
except Exception as e:
raise ValueError(f"illegal source: {src}") from e
self.config = self.src / ".config"
self.bzImage = self.src / "arch/x86_64/boot/bzImage"
self.efi = self.esp / f"gentoo-{self.version.base_version}.efi"
self.modules = self.modules / f"{self.version.base_version}-gentoo"
def __eq__ (self, other):
if not isinstance(other, Kernel):
return False
return self.src == other.src
def __str__ (self):
return (
f"{self.src.name}\n"
f"* version = {self.version}\n"
f"* src = {self.src}\n"
f"* config = {self.config}\n"
f"* bzImage = {self.bzImage}\n"
f"* efi = {self.efi}\n"
f"* modules = {self.modules}\n"
)
@classmethod
def list (cls, descending=True):
"""Get an descending list of available kernels."""
return list(sorted(
( Kernel(src) for src in cls.src.glob("linux-*") ),
key=lambda k: k.version,
reverse=descending
))
@classmethod
def current (cls):
"""
Get the currently running kernel.
Returns:
Kernel: the current kernel, pointed to by ``/usr/src/linux``
"""
return cls(Kernel.linux.resolve())
@classmethod
def latest (cls):
"""
Get the latest available kernel.
Returns:
Kernel: the newest kernel under ``/usr/src``
"""
return cls.list()[0]
def cli (f):
"""A top level exception handling decorator for script main functions."""
@functools.wraps(f)
def handler (argv=sys.argv[1:]):
try:
r = f(argv)
return 0 if r is None else r
except Exception as e:
out.eerror(str(e))
sys.exit(1)
return handler
def mount (path):
"""Decorator ensuring a given path is mounted."""
def wrapper (f):
@functools.wraps(f)
def mounter (*args, **kwargs):
mounted = False
try:
subprocess.run(
["mount", path],
capture_output=True,
check=True
)
mounted = True
except subprocess.CalledProcessError as e:
msg = e.stderr.decode().strip()
if f"already mounted on {path}" not in msg:
raise RuntimeError(e.stderr.decode().splitlines()[0])
r = f(*args, **kwargs)
if mounted:
subprocess.run(["umount", path], check=True)
return r
return mounter
return wrapper
@cli
def configure (argv):
"""
Configure a kernel.
===================
Runs ``make menuconfig`` in the latest kernel's source directory if it is
already configured, the current config missing or no other kernel is
installed. Otherwise, configure the latest kernel with ``make oldconfig``,
using the current kernel config.
Command Line Arguments
----------------------
``-s <src>``
kernel source directory (default: latest)
``-q``
be quiet
Files
-----
The following files are created in the new kernel's source directory,
storing details about changes in the configuration:
``.newoptions``
Newly added configuration options w.r.t. to the previous config (the
output of ``make listnewconfig``).
Process Outline
---------------
This command is a mere wrapper to::
if [[ ! -f ${old}/.config || $(ls -1dq /usr/src/linux-* | wc -l) == "1"]]
then
cd ${new}
make menuconfig
else
cp -n ${old}/.config ${new}
cd ${new}
make listnewconfig > .newoptions
make oldconfig || exit
fi
"""
parser = argparse.ArgumentParser(
prog="ekernel-configure",
description="Configure a kernel.",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"-l",
dest="list",
action="store_true",
help="print newly added config options and exit"
)
parser.add_argument(
"-d",
dest="delete",
action="store_true",
help="delete config (perform a fresh install / reconfigure)"
)
parser.add_argument(
"-s",
metavar="<src>",
dest="kernel",
type=Kernel,
default=Kernel.latest(),
help="kernel source directory (default: latest)"
)
parser.add_argument(
"-q",
dest="quiet",
action="store_true",
help="be quiet"
)
args = parser.parse_args(argv)
out.quiet = args.quiet
newoptions = args.kernel.src / ".newoptions"
# check if current kernel config exists
try:
oldconfig = Kernel.current().config
except FileNotFoundError:
oldconfig = Kernel.esp / "FILENOTFOUND"
# change to source directory
os.chdir(args.kernel.src)
# delete config - reconfigure
if args.delete and args.kernel.config.exists():
out.einfo(f"deleting {args.kernel.config}")
args.kernel.config.unlink()
# make oldconfig
if not args.kernel.config.exists() and oldconfig.exists():
# copy oldconfig
out.einfo(f"copying {out.hilite(oldconfig)}")
shutil.copy(oldconfig, args.kernel.config)
# store newly added options
out.einfo(f"running {out.hilite('make listnewconfig')}")
make = subprocess.run(["make", "listnewconfig"], capture_output=True)
newoptions.write_text(make.stdout.decode())
# configure
if not args.list:
out.einfo(f"running {out.hilite('make oldconfig')}")
subprocess.run(["make", "oldconfig"], check=True)
# make menuconfig
elif not args.list:
out.einfo(f"running {out.hilite('make menuconfig')}")
subprocess.run(["make", "menuconfig"], check=True)
# check if we should print new options
if args.list:
if not newoptions.exists():
raise FileNotFoundError(f"missing {newoptions}")
for l in newoptions.read_text().splitlines():
opt, val = l.split("=", maxsplit=1)
out.print(f" {opt} = {val}")
@cli
def build (argv):
"""
Build a kernel.
===============
Build the latest kernel found in ``/usr/src`` or any other by supplying
a source directory and install it's modules.
Command Line Arguments
----------------------
``-j <jobs>``
number of parallel make jobs (default: 4)
``-s <src>``
kernel source directory (default: latest)
``-q``
be quiet
Process Outline
---------------
Changes into the kernel's source directory and builds the image.
This command is a mere wrapper to::
cd ${new}
make -k ${jobs} && make modules_install
"""
parser = argparse.ArgumentParser(
prog="ekernel-build",
description="Build a kernel.",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"-j",
metavar="<jobs>",
dest="jobs",
type=int,
default=int(jobs),
help=f"number of parallel make jobs (default: {jobs})"
)
parser.add_argument(
"-s",
metavar="<src>",
dest="kernel",
type=Kernel,
default=Kernel.latest(),
help="kernel source directory (default: latest)"
)
parser.add_argument(
"-q",
dest="quiet",
action="store_true",
help="be quiet"
)
args = parser.parse_args(argv)
out.quiet = args.quiet
# check if config exists
if not args.kernel.config.exists():
raise FileNotFoundError(f"missing config: {args.kernel.config}")
# change directory
os.chdir(args.kernel.src)
# build and install modules
out.einfo(f"building {out.hilite(args.kernel.src.name)}")
subprocess.run(["make", "-j", str(args.jobs)], check=True)
out.einfo("installing modules")
subprocess.run(["make", "modules_install"], check=True)
@cli
@mount("/boot")
def install (argv):
"""
Install a kernel.
=================
Install the latest kernel found in ``/usr/src`` or any other by supplying
it's source directory.
Command Line Arguments
----------------------
``-s <src>``
kernel source directory (default: latest)
``-q``
be quiet
Process Outline
---------------
Update ``/usr/src`` to the given kernel, install it's ``bzImage`` into the
EFI system partition as ``bootx64.efi`` and add a backup copy
``gentoo-${version}.efi`` in case something goes horribly wrong.
This command is a mere wrapper to::
eselect kernel set $(basename ${src})
mount /boot
esp=/boot/EFI/Gentoo
cp ${src}/arch/x86_64/boot/bzImage ${esp}/bootx64.efi
cp ${src}/arch/x86_64/boot/bzImage ${esp}/gentoo-${version}.efi
"""
parser = argparse.ArgumentParser(
prog="ekernel-install",
description="Install a kernel.",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"-s",
metavar="<src>",
dest="kernel",
type=Kernel,
default=Kernel.latest(),
help="kernel source directory (default: latest)"
)
parser.add_argument(
"-q",
dest="quiet",
action="store_true",
help="be quiet"
)
args = parser.parse_args(argv)
out.quiet = args.quiet
# check if bzImage exists
if not args.kernel.bzImage.exists():
raise FileNotFoundError(f"missing bzImage {args.kernel.bzImage}")
# update symlink to the new source directory
out.einfo(
"updating symlink "
f"{out.hilite(args.kernel.linux)}{out.hilite(args.kernel.src)}"
)
subprocess.run(
["eselect", "kernel", "set", args.kernel.src.name],
check=True
)
# copy boot image
out.einfo(f"creating boot image {out.hilite(args.kernel.bootx64)}")
shutil.copy(args.kernel.bzImage, args.kernel.bootx64)
# create backup
out.einfo(f"creating backup image {out.hilite(args.kernel.efi)}")
shutil.copy(args.kernel.bzImage, args.kernel.efi)
# rebuild external modules
out.einfo(f"rebuilding external kernel modules")
subprocess.run(["emerge", "@module-rebuild"], check=True)
@cli
@mount("/boot")
def clean (argv):
"""
Remove unused kernel leftovers.
===============================
Remove unused kernel source directories, modules and boot images.
The default is to keep the ``k`` previous kernel versions in case something
goes horribly wrong.
Command Line Arguments
----------------------
``-k <num>``
keep the previous ``<num>`` kernels (default: 1)
``-n``
perform a dry run (show what would be removed)
``-q``
be quiet
"""
parser = argparse.ArgumentParser(
prog="ekernel-clean",
description="Remove unused kernel leftovers.",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"-k",
metavar="<keep>",
dest="keep",
type=int,
default=1,
help="keep the previous <num> bootable kernels (default: 1)"
)
parser.add_argument(
"-n",
dest="dry",
action="store_true",
help="perform a dry run (show what would be removed)"
)
parser.add_argument(
"-q",
dest="quiet",
action="store_true",
help="be quiet"
)
args = parser.parse_args(argv)
out.quiet = args.quiet
if args.keep < 0:
raise ValueError("invalid int value: must be greater equal zero")
# kernels to remove
kernels = Kernel.list()
def obsolete (k):
if args.keep and k.efi.exists() and k.modules.exists():
args.keep -= 1
return False
return True
leftovers = filter(obsolete, kernels[kernels.index(Kernel.current()) + 1:])
# dry run
if args.dry:
out.einfo("the following kernels will be removed:")
for k in leftovers:
print(f" {colorize('BAD', '')} {k.src.name}")
return
# run depclean
subprocess.run(["emerge", "-cq", "gentoo-sources"])
# remove leftovers
for k in leftovers:
out.einfo(f"removing {out.hilite(k.src.name)}")
shutil.rmtree(k.src)
shutil.rmtree(k.modules, ignore_errors=True)
k.efi.unlink(missing_ok=True)
@cli
def commit (argv):
"""
Commit the current kernel config.
=================================
Commit the current kernel config with a detailed commit message.
This command module is a mere wrapper to::
git add -f /usr/src/linux/.config
git commit -m "${msg}"
Command Line Arguments
----------------------
``-m``
additional information for the commit message
``-n``
perform a dry run (show what would be commited)
``-q``
be quiet
"""
msg = io.StringIO()
def git (argv: list[str]):
"""Run git, capture output and check exit code."""
return subprocess.run(["git"] + argv, capture_output=True, check=True)
def summarize (diff: list[str]):
"""Generate the summary of changed options."""
def changes (s):
return dict([
x[1:].split("=", maxsplit=1)
for x in diff
if x.startswith(s + "CONFIG") and "CC_VERSION" not in x
])
additions = changes("+")
deletions = changes("-")
changes = {
k: (deletions[k], additions[k])
for k in additions.keys() & deletions.keys()
}
additions = {k: v for k, v in additions.items() if k not in changes}
deletions = {k: v for k, v in deletions.items() if k not in changes}
if additions:
msg.write("\nenabled:\n")
for opt, val in additions.items():
msg.write(f"* {opt} = {val}\n")
if changes:
msg.write("\nchanged:\n")
for opt, (old, new) in changes.items():
msg.write(f"* {opt} = {old}{new}\n")
if deletions:
msg.write("\nremoved:\n")
for opt, val in deletions.items():
msg.write(f"* {opt}\n")
parser = argparse.ArgumentParser(
prog="ekernel-commit",
description="Commit the current kernel config.",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"-m",
metavar="<msg>",
dest="msg",
type=str,
default="",
help="additional information for the commit message"
)
parser.add_argument(
"-n",
dest="dry",
action="store_true",
help="perform a dry run (show what would be commited)"
)
parser.add_argument(
"-q",
dest="quiet",
action="store_true",
help="be quiet"
)
args = parser.parse_args(argv)
out.quiet = args.quiet
# get the kernel under /usr/src/linux
kernel = Kernel.current()
# ensure that a config exists
if not kernel.config.exists():
raise FileNotFoundError(f"missing config: {kernel.config}")
# change to source directory
os.chdir(kernel.src)
# ensure that we're in a git repository
try:
git(["status", "-s"])
except subprocess.CalledProcessError as e:
raise RuntimeError(e.stderr.decode().strip())
# ensure that nothing is staged
try:
git(["diff", "--cached", "--exit-code", "--quiet"])
except subprocess.CalledProcessError as e:
raise RuntimeError("please commit or stash staged changes")
# get git root directory
gitroot = pathlib.Path(
git(["rev-parse", "--show-toplevel"]).stdout.decode().strip()
)
# add unstaged config removals
removals = [
gitroot / (l.rsplit(maxsplit=1)[1])
for l in
git(["-P", "diff", "--name-status"]).stdout.decode().splitlines()
if l.startswith("D") and "usr/src/linux" in l and ".config" in l
]
for r in removals: git(["rm", r])
config_changed = True
# check if current config is tracked already
try:
git(["ls-files", "--error-unmatch", kernel.config])
# config is tracked: check for changes
try:
git(["-P", "diff", "--exit-code", "--quiet", kernel.config])
# config hasn't changed: only removals remain
config_changed = False
if removals:
msg.write("removed old kernel leftovers")
if args.msg: msg.write(f"\n\n{args.msg}")
# config changed
except subprocess.CalledProcessError:
git(["add", "-f", kernel.config])
msg.write("updated kernel config\n")
if args.msg: msg.write(f"\n{args.msg}\n")
summarize(
git(["diff", kernel.config]).stdout.decode().splitlines()
)
# config isn't tracked: kernel has been updated
except subprocess.CalledProcessError:
git(["add", "-f", kernel.config])
# /usr/src/linux/.config.old (previous config stored by make oldconfig)
oldconfig = kernel.src / ".config.old"
# start header
msg.write("kernel ")
# check if .config.old exists
if oldconfig.exists():
# get previous version from .config.old, which starts as follows:
#
# Automatically generated file; DO NOT EDIT.
# Linux/x86 X.Y.Z-gentoo Kernel Configuration
#
with oldconfig.open() as f:
f.readline()
f.readline()
oldversion = version(f.readline())
if oldversion.minor != kernel.version.minor:
msg.write("upgrade")
else:
msg.write("update")
msg.write(f": {oldversion}{kernel.version.base_version}\n")
# append user's message
if args.msg: msg.write(f"\n{args.msg}\n")
# append newly added options (stored in .newoptions)
newoptions = kernel.src / ".newoptions"
if newoptions.exists():
msg.write("\nnew:\n")
with newoptions.open() as f:
for opt in f.readlines():
msg.write(f"* {opt.replace('=', ' = ')}")
# append summary
summarize(list(difflib.unified_diff(
oldconfig.read_text().splitlines(),
kernel.config.read_text().splitlines()
)))
else:
msg.write(f"{kernel.version}")
if args.msg: msg.write(f"\n\n{args.msg}")
# print changes
out.einfo("changes to be committed:")
for l in removals:
out.print(f" {colorize('QAWARN', '-')} {out.hilite(l)}")
if config_changed:
out.print(f" {colorize('INFO', '+')} {out.hilite(kernel.config)}")
# print message
out.einfo("commit message:")
for l in msg.getvalue().splitlines():
out.print(f" {l}" if l else "")
# dry run: revert staged changes
if args.dry:
git(["restore", "--staged", kernel.config])
return
# commit
try:
out.ebegin("committing")
git(["commit", "-m", msg.getvalue()])
out.eend(0)
except subprocess.CalledProcessError as e:
out.eend(1)
raise RuntimeError(e.stderr.decode())
@cli
def update (argv):
"""Custom Gentoo EFI stub kernel updater."""
parser = argparse.ArgumentParser(
prog="ekernel",
description="Custom Gentoo EFI stub kernel updater.",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"-j",
metavar="<jobs>",
dest="jobs",
type=int,
default=int(jobs),
help=f"number of parallel make jobs (default: {jobs})"
)
parser.add_argument(
"-s",
metavar="<src>",
dest="kernel",
type=Kernel,
default=Kernel.latest(),
help="kernel source directory (default: latest)"
)
parser.add_argument(
"-k",
metavar="<keep>",
dest="keep",
type=int,
default=1,
help="keep the previous <num> bootable kernels (default: 1)"
)
parser.add_argument(
"-m",
metavar="<msg>",
dest="msg",
type=str,
default="",
help="additional information for the commit message"
)
parser.add_argument(
"-q",
dest="quiet",
action="store_true",
help="be quiet"
)
args = parser.parse_args(argv)
args.jobs = ["-j", str(args.jobs)]
args.src = ["-s", str(args.kernel.src)]
args.keep = ["-k", str(args.keep)]
args.msg = ["-m", args.msg]
args.quiet = ["-q"] if args.quiet else []
configure(args.quiet + args.src)
build(args.quiet + args.jobs + args.src)
install(args.quiet + args.src)
clean(args.quiet + args.keep)
commit(args.quiet + args.msg)

25
pyproject.toml Normal file
View File

@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools >= 61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ekernel"
dynamic = ["version"]
dependencies = ["packaging", "portage"]
readme = "README.md"
classifiers = [
"Development Status :: 3 - Alpha",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.12",
]
[project.scripts]
ekernel = "ekernel:update"
ekernel-configure = "ekernel:configure"
ekernel-build = "ekernel:build"
ekernel-install = "ekernel:install"
ekernel-clean = "ekernel:clean"
ekernel-commit = "ekernel:commit"
[tool.setuptools.dynamic]
version = {attr = "ekernel.__version__"}

3
setup.py Normal file
View File

@ -0,0 +1,3 @@
import setuptools
setuptools.setup()

121
tests/__init__.py Normal file
View File

@ -0,0 +1,121 @@
import functools
import io
import subprocess
import sys
import portage.output
from pkgutil import resolve_name as resolve
import ekernel
# disable output
ekernel.out.quiet = True
def git (argv: list[str]):
return subprocess.run(["git"] + argv, capture_output=True, check=True)
def capture_stdout (f):
"""A decorator for capturing stdout in a io.StringIO object."""
@functools.wraps(f)
def capture (*args, **kwargs):
quiet = ekernel.out.quiet
ekernel.out.quiet = False
stdout = sys.stdout
sys.stdout = io.StringIO()
r = f(*args, **kwargs)
sys.stdout = stdout
ekernel.out.quiet = quiet
return r
return capture
def capture_stderr (f):
"""A decorator for capturing stderr in a io.StringIO object."""
@functools.wraps(f)
def capture (*args, **kwargs):
quiet = ekernel.out.quiet
ekernel.out.quiet = False
stderr = sys.stderr
sys.stderr = io.StringIO()
r = f(*args, **kwargs)
sys.stderr = stderr
ekernel.out.quiet = quiet
return r
return capture
def colorless (f):
"""A decorator for disabling portage's colorful output."""
@functools.wraps(f)
def nocolor (*args, **kwargs):
havecolor = portage.output.havecolor
portage.output.havecolor = 0
r = f(*args, **kwargs)
portage.output.havecolor = havecolor
return r
return nocolor
class Interceptor:
"""Dynamically intercept, trace and/or replace arbitrary function calls."""
class Tracer:
def __init__ (self, interceptor, target, log, call):
self.name = f"{target.__module__}.{target.__qualname__}"
self.parent = resolve(self.name.rsplit(".", 1)[0])
self.interceptor = interceptor
self.target = target
self.log = log
self.call = call
def start (self):
def call (*args, **kwargs):
if self.log:
self.interceptor.trace.append((self, (args, kwargs)))
if callable(self.call):
return self.call(self, *args, **kwargs)
setattr(self.parent, self.target.__name__, call)
def stop (self):
setattr(self.parent, self.target.__name__, self.target)
def __init__ (self):
self.targets = {}
self.trace = []
def __str__ (self):
s = io.StringIO()
for tracer, (args, kwargs) in self.trace:
s.write(f"{tracer.name}\n")
for a in args:
s.write(f" {a}\n")
for k, v in kwargs.items():
s.write(f" {k} = {v}\n")
return s.getvalue()
def add (self, target, log=True, call=None):
"""
Intercept calls to the given function.
Args:
target: the intercepted function object
log (bool): trace calls if True
call: function to be called instead
"""
if target in self.targets:
raise RuntimeError(f"{self.targets[target].name} already caught")
if not callable(target):
raise RuntimeError(f"{target.__name__} is not callable")
self.targets[target] = self.Tracer(self, target, log, call)
def remove (self, target):
"""Stop intercepting calls to the given function."""
if target not in self.targets:
raise RuntimeError(f"{target.__name__} not being caught")
del self.targets[target]
def start (self):
"""Start intercepting calls to the registered functions."""
for tracer in self.targets.values(): tracer.start()
def stop (self):
"""Stop intercepting calls to the registered functions."""
for tracer in self.targets.values(): tracer.stop()

0
tests/data/__init__.py Normal file
View File

104
tests/data/kernel.py Normal file
View File

@ -0,0 +1,104 @@
"""Setup the kernel test environment."""
import pathlib
import shutil
import tempfile
from ekernel import Kernel
# create temporary directory
tmpdir = tempfile.TemporaryDirectory()
root = pathlib.Path(tmpdir.name)
# kernel source directory
src = root / "usr/src"
# kernel source symlink
linux = src / "linux"
# kernel module directory
modules = root / "lib/modules"
# EFI system partition
esp = root / "boot/EFI/Gentoo"
# boot image
bootx64 = esp / "bootx64.efi"
# change Kernel class' root directory
Kernel.src = src
Kernel.linux = linux
Kernel.modules = modules
Kernel.esp = esp
Kernel.bootx64 = bootx64
# list of installed kernels
kernels = []
sources = [
src / "linux-5.15.23-gentoo",
# all except the lastest have been built
src / "linux-5.15.16-gentoo",
src / "linux-5.15.3-gentoo",
src / "linux-5.15.2-gentoo",
src / "linux-5.15.1-gentoo-r1"
]
# currently installed kernel
current = sources[1]
# latest available kernel
latest = sources[0]
# current config
oldconfig = f"""\
#
# Automatically generated file; DO NOT EDIT.
# Linux/x86 {current}-gentoo Kernel Configuration
#
CONFIG_A=y
CONFIG_B=y
CONFIG_C=y
"""
# new options
newoptions = """\
CONFIG_D=n
CONFIG_E=n
CONFIG_F=n
"""
# new config
newconfig = """\
CONFIG_A=y
CONFIG_C=m
CONFIG_D=y
CONFIG_F=y
"""
def setup ():
"""Setup the kernel test environment."""
# remove any existing files
for p in root.glob("*"):
shutil.rmtree(p)
# create EFI system partition
esp.mkdir(parents=True)
# create Kernels
for s in sources: s.mkdir(parents=True)
global kernels
kernels = [ Kernel(s) for s in sources ]
# create config and build files, expect for the latest
for k in kernels:
k.bzImage.parent.mkdir(parents=True)
if k.src == latest: continue
if k.src == current:
k.config.write_text(oldconfig)
else:
k.config.touch()
k.bzImage.touch()
k.efi.touch()
k.modules.mkdir(parents=True)
# symlink to old source directory
linux.symlink_to(current)

87
tests/test_build.py Normal file
View File

@ -0,0 +1,87 @@
import pathlib
import subprocess
import unittest
from ekernel import Kernel
from tests import capture_stderr, Interceptor
import tests.data.kernel as data
import ekernel
def run (*argv):
return ekernel.build(list(argv))
class Tests (unittest.TestCase):
def setUp (self):
# start interceptor
self.interceptor = Interceptor()
def run (tracer, *args, **kwargs):
if args[0][0] == "make" and args[0][1] == "-j":
self.kernel.bzImage.touch()
self.interceptor.add(subprocess.run, call=run)
self.interceptor.start()
# setup test environment
data.setup()
self.kernel = Kernel.latest()
self.kernel.config.touch()
self.jobs = "4"
def tearDown (self):
# stop interceptor
self.interceptor.stop()
def check_build (self):
self.assertEqual(pathlib.Path.cwd(), self.kernel.src)
trace_it = iter(self.interceptor.trace)
# make -j <jobs>
tracer, (args, kwargs) = next(trace_it)
self.assertEqual(tracer.name, "subprocess.run")
self.assertEqual(args, (["make", "-j", self.jobs],))
self.assertEqual(kwargs, {"check": True})
self.assertTrue(self.kernel.bzImage.exists())
# make modules_install
tracer, (args, kwargs) = next(trace_it)
self.assertEqual(tracer.name, "subprocess.run")
self.assertEqual(args, (["make", "modules_install"],))
self.assertEqual(kwargs, {"check": True})
def test_build (self):
self.assertEqual(run("-q"), 0)
self.check_build()
def test_build_jobs (self):
self.jobs = "128"
self.assertEqual(run("-q", "-j", self.jobs), 0)
self.check_build()
@capture_stderr
def test_build_jobs_illegal (self):
with self.assertRaises(SystemExit):
run("-j", "foo")
def test_build_source (self):
self.kernel = Kernel.current()
self.kernel.config.touch()
self.assertEqual(run("-q", "-s", str(data.current)), 0)
self.check_build()
@capture_stderr
def test_build_source_missing (self):
with self.assertRaises(SystemExit):
run("-s", str(data.src / "linux-0.0.0-gentoo"))
def test_build_source_missing_config (self):
Kernel.latest().config.unlink()
with self.assertRaises(SystemExit):
self.assertEqual(run("-q", "-s", str(data.latest)), 1)
@capture_stderr
def test_build_source_illegal (self):
with self.assertRaises(SystemExit):
run("-s", str(data.root))
def test_build_jobs_source (self):
self.jobs = "128"
self.assertEqual(run("-q", "-j", self.jobs, "-s", str(data.latest)), 0)
self.check_build()

106
tests/test_clean.py Normal file
View File

@ -0,0 +1,106 @@
import io
import os
import shutil
import subprocess
import sys
import unittest
from ekernel import Kernel
from tests import capture_stdout, colorless, Interceptor
import tests.data.kernel as data
import ekernel
def run (*argv):
return ekernel.clean(list(argv))
class Tests (unittest.TestCase):
def setUp (self):
# setup test environment
data.setup()
# update src symlink to new kernel
data.linux.unlink()
data.linux.symlink_to(data.latest)
# initialize git repository
os.chdir(data.root)
for k in data.kernels[:-2]:
if not k.config.exists():
k.config.touch()
if not k.modules.exists():
k.modules.mkdir(parents=True)
if not k.efi.exists():
k.efi.touch()
# start interceptor
self.interceptor = Interceptor()
self.interceptor.add(subprocess.run, call=True)
self.interceptor.start()
def tearDown (self):
# stop interceptor
self.interceptor.stop()
def check_clean (self, keep=1):
split = data.kernels.index(Kernel.current()) + keep + 1
trace_it = iter(self.interceptor.trace)
# mount /boot
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})
# emerge -cq gentoo-sources
tracer, (args, kwargs) = next(trace_it)
self.assertEqual(tracer.name, "subprocess.run")
self.assertEqual(args, (["emerge", "-cq", "gentoo-sources"],))
self.assertEqual(kwargs, {})
for k in data.kernels[:split]:
self.assertTrue(k.src.exists())
self.assertTrue(k.modules.exists())
self.assertTrue(k.efi.exists())
for k in data.kernels[split:]:
self.assertFalse(k.src.exists())
self.assertFalse(k.modules.exists())
self.assertFalse(k.efi.exists())
# umount /boot
tracer, (args, kwargs) = next(trace_it)
self.assertEqual(tracer.name, "subprocess.run")
self.assertEqual(args, (["umount", "/boot"],))
self.assertEqual(kwargs, {"check": True})
def test_clean (self):
self.assertEqual(run("-q"), 0)
self.check_clean()
def test_clean_missing_efi (self):
data.kernels[-1].efi.unlink()
self.assertEqual(run("-q"), 0)
self.check_clean()
def test_clean_missing_modules (self):
shutil.rmtree(data.kernels[-1].modules)
self.assertEqual(run("-q"), 0)
self.check_clean()
def test_clean_keep_2 (self):
self.assertEqual(run("-q", "-k", "2"), 0)
self.check_clean(2)
def test_clean_keep_none (self):
self.assertEqual(run("-q", "-k", "0"), 0)
self.check_clean(0)
def test_clean_keep_gt_available (self):
self.assertEqual(run("-q", "-k", "10"), 0)
self.check_clean(10)
@colorless
@capture_stdout
def test_clean_dry_run (self):
self.assertEqual(run("-n"), 0)
for src in data.sources:
self.assertTrue(src.exists())
expected = io.StringIO()
expected.write(f" * the following kernels will be removed:\n")
for k in data.kernels[2:]:
expected.write(f"{k.src.name}\n")
self.assertEqual(sys.stdout.getvalue(), expected.getvalue())

223
tests/test_commit.py Normal file
View File

@ -0,0 +1,223 @@
import os
import shutil
import subprocess
import sys
import unittest
from ekernel import Kernel
from tests import git, capture_stdout, capture_stderr, colorless
import tests.data.kernel as data
import ekernel
def run (*argv):
return ekernel