diff options
Diffstat (limited to 'src/kernel-install/60-ukify.install.in')
-rwxr-xr-x | src/kernel-install/60-ukify.install.in | 265 |
1 files changed, 265 insertions, 0 deletions
diff --git a/src/kernel-install/60-ukify.install.in b/src/kernel-install/60-ukify.install.in new file mode 100755 index 0000000..be1e21b --- /dev/null +++ b/src/kernel-install/60-ukify.install.in @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-or-later +# -*- mode: python-mode -*- +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# systemd is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with systemd; If not, see <https://www.gnu.org/licenses/>. + +# pylint: disable=import-outside-toplevel,consider-using-with,disable=redefined-builtin + +import argparse +import os +import runpy +import shlex +from shutil import which +from pathlib import Path +from typing import Optional + +__version__ = '{{PROJECT_VERSION}} ({{GIT_VERSION}})' + +try: + VERBOSE = int(os.environ['KERNEL_INSTALL_VERBOSE']) > 0 +except (KeyError, ValueError): + VERBOSE = False + +# Override location of ukify and the boot stub for testing and debugging. +UKIFY = os.getenv('KERNEL_INSTALL_UKIFY', which('ukify')) +BOOT_STUB = os.getenv('KERNEL_INSTALL_BOOT_STUB') + + +def shell_join(cmd): + # TODO: drop in favour of shlex.join once shlex.join supports pathlib.Path. + return ' '.join(shlex.quote(str(x)) for x in cmd) + +def log(*args, **kwargs): + if VERBOSE: + print(*args, **kwargs) + +def path_is_readable(p: Path, dir=False) -> None: + """Verify access to a file or directory.""" + try: + p.open().close() + except IsADirectoryError: + if dir: + return + raise + +def mandatory_variable(name): + try: + return os.environ[name] + except KeyError as e: + raise KeyError(f'${name} must be set in the environment') from e + +def parse_args(args=None): + p = argparse.ArgumentParser( + description='kernel-install plugin to build a Unified Kernel Image', + allow_abbrev=False, + usage='60-ukify.install COMMAND KERNEL_VERSION ENTRY_DIR KERNEL_IMAGE INITRD…', + ) + + # Suppress printing of usage synopsis on errors + p.error = lambda message: p.exit(2, f'{p.prog}: error: {message}\n') + + p.add_argument('command', + metavar='COMMAND', + help="The action to perform. Only 'add' is supported.") + p.add_argument('kernel_version', + metavar='KERNEL_VERSION', + help='Kernel version string') + p.add_argument('entry_dir', + metavar='ENTRY_DIR', + type=Path, + nargs='?', + help='Type#1 entry directory (ignored)') + p.add_argument('kernel_image', + metavar='KERNEL_IMAGE', + type=Path, + nargs='?', + help='Kernel binary') + p.add_argument('initrd', + metavar='INITRD…', + type=Path, + nargs='*', + help='Initrd files') + p.add_argument('--version', + action='version', + version=f'systemd {__version__}') + + opts = p.parse_args(args) + + if opts.command == 'add': + opts.staging_area = Path(mandatory_variable('KERNEL_INSTALL_STAGING_AREA')) + path_is_readable(opts.staging_area, dir=True) + + opts.entry_token = mandatory_variable('KERNEL_INSTALL_ENTRY_TOKEN') + opts.machine_id = mandatory_variable('KERNEL_INSTALL_MACHINE_ID') + + return opts + +def we_are_wanted() -> bool: + KERNEL_INSTALL_LAYOUT = os.getenv('KERNEL_INSTALL_LAYOUT') + + if KERNEL_INSTALL_LAYOUT != 'uki': + log(f'{KERNEL_INSTALL_LAYOUT=}, quitting.') + return False + + KERNEL_INSTALL_UKI_GENERATOR = os.getenv('KERNEL_INSTALL_UKI_GENERATOR') or 'ukify' + + if KERNEL_INSTALL_UKI_GENERATOR != 'ukify': + log(f'{KERNEL_INSTALL_UKI_GENERATOR=}, quitting.') + return False + + log('KERNEL_INSTALL_LAYOUT and KERNEL_INSTALL_UKI_GENERATOR are good') + return True + + +def input_file_location( + filename: str, + *search_directories: str) -> Optional[Path]: + + if root := os.getenv('KERNEL_INSTALL_CONF_ROOT'): + search_directories = (root,) + elif not search_directories: + # This is the default search path. + search_directories = ('/etc/kernel', + '/usr/lib/kernel') + + for dir in search_directories: + p = Path(dir) / filename + if p.exists(): + return p + return None + + +def uki_conf_location() -> Optional[Path]: + return input_file_location('uki.conf', + '/etc/kernel') + + +def devicetree_config_location() -> Optional[Path]: + return input_file_location('devicetree') + + +def devicetree_file_location(opts) -> Optional[Path]: + # This mirrors the logic in 90-loaderentry.install. Keep in sync. + configfile = devicetree_config_location() + if configfile is None: + return None + + devicetree = configfile.read_text().strip() + if not devicetree: + raise ValueError(f'{configfile!r} is empty') + + path = input_file_location( + devicetree, + f'/usr/lib/firmware/{opts.kernel_version}/device-tree', + f'/usr/lib/linux-image-{opts.kernel_version}', + f'/usr/lib/modules/{opts.kernel_version}/dtb', + ) + if path is None: + raise FileNotFoundError(f'DeviceTree file {devicetree} not found') + return path + + +def kernel_cmdline_base() -> list[str]: + path = input_file_location('cmdline') + if path: + return path.read_text().split() + + # If we read /proc/cmdline, we need to do some additional filtering. + options = Path('/proc/cmdline').read_text().split() + return [opt for opt in options + if not opt.startswith(('BOOT_IMAGE=', 'initrd='))] + + +def kernel_cmdline(opts) -> str: + options = kernel_cmdline_base() + + # If the boot entries are named after the machine ID, then suffix the kernel + # command line with the machine ID we use, so that the machine ID remains + # stable, even during factory reset, in the initrd (where the system's machine + # ID is not directly accessible yet), and if the root file system is volatile. + if (opts.entry_token == opts.machine_id and + not any(opt.startswith('systemd.machine_id=') for opt in options)): + options += [f'systemd.machine_id={opts.machine_id}'] + + # TODO: we unconditionally set the cmdline here, ignoring the setting in + # the config file. Should we not do that? + + # Prepend a space so that '@' does not get misinterpreted + return ' ' + ' '.join(options) + + +def initrd_list(opts) -> list[Path]: + microcode = sorted(opts.staging_area.glob('microcode*')) + initrd = sorted(opts.staging_area.glob('initrd*')) + + # Order taken from 90-loaderentry.install + return [*microcode, *opts.initrd, *initrd] + + +def call_ukify(opts): + # Punish me harder. + # We want this: + # ukify = importlib.machinery.SourceFileLoader('ukify', UKIFY).load_module() + # but it throws a DeprecationWarning. + # https://stackoverflow.com/questions/67631/how-can-i-import-a-module-dynamically-given-the-full-path + # https://github.com/python/cpython/issues/65635 + # offer "explanations", but to actually load a python file without a .py extension, + # the "solution" is 4+ incomprehensible lines. + # The solution with runpy gives a dictionary, which isn't great, but will do. + ukify = runpy.run_path(UKIFY, run_name='ukify') + + # Create "empty" namespace. We want to override just a few settings, so it + # doesn't make sense to configure everything. We pretend to parse an empty + # argument set to prepopulate the namespace with the defaults. + opts2 = ukify['create_parser']().parse_args(['build']) + + opts2.config = uki_conf_location() + opts2.uname = opts.kernel_version + opts2.linux = opts.kernel_image + opts2.initrd = initrd_list(opts) + # Note that 'uki.efi' is the name required by 90-uki-copy.install. + opts2.output = opts.staging_area / 'uki.efi' + + if devicetree := devicetree_file_location(opts): + opts2.devicetree = devicetree + + opts2.cmdline = kernel_cmdline(opts) + if BOOT_STUB: + opts2.stub = BOOT_STUB + + # opts2.summary = True + + ukify['apply_config'](opts2) + ukify['finalize_options'](opts2) + ukify['check_inputs'](opts2) + ukify['make_uki'](opts2) + + log(f'{opts2.output} has been created') + + +def main(): + opts = parse_args() + if opts.command != 'add': + return + if not we_are_wanted(): + return + + call_ukify(opts) + + +if __name__ == '__main__': + main() |