ukify: rework option parsing to support a config file

In some ways this is similar to mkosi: we have a argparse.ArgumentParser()
with a bunch of options, and a configparser.ConfigParser() with an
overlapping set of options. Many options are settable in both places, but
not all. In mkosi, we define this in three places (a dataclass, and a
function for argparse, and a function for configparser). Here, we have one
huge list of ConfigItem instances. Each instance specifies the full metadata
for both parsers. Argparse generates a --help string for all the options,
and we also append a config file sample to --help based on the ConfigItem
data:

$ python src/ukify/ukify.py --help|tail -n 25
config file:
  [UKI]
  Linux = LINUX
  Initrd = INITRD…
  Cmdline = TEXT|@PATH
  OSRelease = TEXT|@PATH
  DeviceTree = PATH
  Splash = BMP
  PCRPKey = KEY
  Uname = VERSION
  EFIArch = ia32|x64|arm|aa64|riscv64
  Stub = STUB
  PCRBanks = BANK…
  SigningEngine = ENGINE
  SecureBootPrivateKey = SB_KEY
  SecureBootCertificate = SB_CERT
  SignKernel = SIGN_KERNEL

  [PCRSignature:NAME]
  PCRPrivateKey = PATH
  PCRPublicKey = PATH
  Phases = PHASE-PATH…

While writing this I needed to check the argument parsing, so I added
a --summary switch. It just pretty-prints the resulting option dictionary:

$ python src/ukify/ukify.py /efi//3a9d668b4db749398a4a5e78a03bffa5/6.2.11-300.fc38.x86_64/linux /efi//3a9d668b4db749398a4a5e78a03bffa5/6.2.11-300.fc38.x86_64/initrd --pcr-private-key=PRIV.key --pcr-public-key=PUB.key --config=man/ukify-example.conf --summary
Host arch 'x86_64', EFI arch 'x64'
{'_groups': [0, 'initrd', 'system'],
 'cmdline': 'A1 B2 C3',
 'config': 'man/ukify-example.conf',
 'devicetree': None,
 'efi_arch': 'x64',
 'initrd': [PosixPath('initrd1'),
            PosixPath('initrd2'),
            PosixPath('initrd3'),
            PosixPath('/efi/3a9d668b4db749398a4a5e78a03bffa5/6.2.11-300.fc38.x86_64/initrd')],
 'linux': PosixPath('/efi/3a9d668b4db749398a4a5e78a03bffa5/6.2.11-300.fc38.x86_64/linux'),
 'measure': None,
 'os_release': PosixPath('/etc/os-release'),
 'output': 'linux.efi',
 'pcr_banks': ['sha1', 'sha384'],
 'pcr_private_keys': [PosixPath('PRIV.key'),
                      PosixPath('pcr-private-initrd-key.pem'),
                      PosixPath('pcr-private-system-key.pem')],
 'pcr_public_keys': [PosixPath('PUB.key'),
                     PosixPath('pcr-public-initrd-key.pem'),
                     PosixPath('pcr-public-system-key.pem')],
 'pcrpkey': None,
 'phase_path_groups': [None,
                       ['enter-initrd'],
                       ['enter-initrd:leave-initrd',
                        'enter-initrd:leave-initrd:sysinit',
                        'enter-initrd:leave-initrd:sysinit:ready']],
 'sb_cert': PosixPath('mkosi.secure-boot.crt'),
 'sb_key': PosixPath('mkosi.secure-boot.key'),
 'sections': [],
 'sign_kernel': None,
 'signing_engine': None,
 'splash': None,
 'stub': PosixPath('/usr/lib/systemd/boot/efi/linuxx64.efi.stub'),
 'summary': True,
 'tools': None,
 'uname': None}

With --summary, existence of input paths is not checked. I think we'll
want to show them, instead of throwing an error, but in red, similarly to
'bootctl list'.

This also fixes tests which were failing with e.g.
E       FileNotFoundError: [Errno 2] No such file or directory: '/ARG1'
=========================== short test summary info ============================
FAILED ../src/ukify/test/test_ukify.py::test_parse_args_minimal - FileNotFoun...
FAILED ../src/ukify/test/test_ukify.py::test_parse_args_many - FileNotFoundEr...
FAILED ../src/ukify/test/test_ukify.py::test_parse_sections - FileNotFoundErr...
=================== 3 failed, 10 passed, 3 skipped in 1.51s ====================
This commit is contained in:
Zbigniew Jędrzejewski-Szmek 2023-04-20 20:22:25 +02:00
parent 3f7e77fae1
commit 5143a47a81

View file

@ -22,6 +22,7 @@
# pylint: disable=too-many-branches,fixme
import argparse
import configparser
import collections
import dataclasses
import fnmatch
@ -29,10 +30,12 @@ import itertools
import json
import os
import pathlib
import pprint
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
import typing
@ -84,18 +87,6 @@ def shell_join(cmd):
return ' '.join(shlex.quote(str(x)) for x in cmd)
def path_is_readable(s: typing.Optional[str]) -> typing.Optional[pathlib.Path]:
"""Convert a filename string to a Path and verify access."""
if s is None:
return None
p = pathlib.Path(s)
try:
p.open().close()
except IsADirectoryError:
pass
return p
def round_up(x, blocksize=4096):
return (x + blocksize - 1) // blocksize * blocksize
@ -337,11 +328,13 @@ def check_inputs(opts):
if name in {'output', 'tools'}:
continue
if not isinstance(value, pathlib.Path):
continue
# Open file to check that we can read it, or generate an exception
value.open().close()
if isinstance(value, pathlib.Path):
# Open file to check that we can read it, or generate an exception
value.open().close()
elif isinstance(value, list):
for item in value:
if isinstance(item, pathlib.Path):
item.open().close()
check_splash(opts.splash)
@ -668,157 +661,412 @@ def make_uki(opts):
print(f"Wrote {'signed' if opts.sb_key else 'unsigned'} {opts.output}")
def parse_args(args=None):
@dataclasses.dataclass(frozen=True)
class ConfigItem:
@staticmethod
def config_list_prepend(namespace, group, dest, value):
"Prepend value to namespace.<dest>"
assert not group
old = getattr(namespace, dest, [])
setattr(namespace, dest, value + old)
@staticmethod
def config_set_if_unset(namespace, group, dest, value):
"Set namespace.<dest> to value only if it was None"
assert not group
if getattr(namespace, dest) is None:
setattr(namespace, dest, value)
@staticmethod
def config_set_group(namespace, group, dest, value):
"Set namespace.<dest>[idx] to value, with idx derived from group"
if group not in namespace._groups:
namespace._groups += [group]
idx = namespace._groups.index(group)
old = getattr(namespace, dest, None)
if old is None:
old = []
setattr(namespace, dest,
old + ([None] * (idx - len(old))) + [value])
@staticmethod
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()
if s_l in {'1', 'true', 'yes', 'y', 't', 'on'}:
return True
if s_l in {'0', 'false', 'no', 'n', 'f', 'off'}:
return False
raise ValueError('f"Invalid boolean literal: {s!r}')
# arguments for argparse.ArgumentParser.add_argument()
name: typing.Union[str, typing.List[str]]
dest: str = None
metavar: str = None
type: typing.Callable = None
nargs: str = None
action: typing.Callable = None
default: typing.Any = None
version: str = None
choices: typing.List[str] = None
help: str = None
# metadata for config file parsing
config_key: str = None
config_push: typing.Callable[..., ...] = config_set_if_unset
def _names(self) -> typing.Tuple[str]:
return self.name if isinstance(self.name, tuple) else (self.name,)
def argparse_dest(self) -> str:
# It'd be nice if argparse exported this, but I don't see that in the API
if self.dest:
return self.dest
return self._names()[0].lstrip('-').replace('-', '_')
def add_to(self, parser: argparse.ArgumentParser):
kwargs = { key:val
for key in dataclasses.asdict(self)
if (key not in ('name', 'config_key', 'config_push') and
(val := getattr(self, key)) is not None) }
args = self._names()
parser.add_argument(*args, **kwargs)
def apply_config(self, namespace, section, group, key, value) -> None:
assert f'{section}/{key}' == self.config_key
dest = self.argparse_dest()
if self.action == argparse.BooleanOptionalAction:
# We need to handle this case separately: the options are called
# --foo and --no-foo, and no argument is parsed. But in the config
# file, we have Foo=yes or Foo=no.
conv = self.parse_boolean
elif self.type:
conv = self.type
else:
conv = lambda s:s
if self.nargs == '*':
value = [conv(v) for v in value.split()]
else:
value = conv(value)
self.config_push(namespace, group, dest, value)
def config_example(self) -> typing.Tuple[typing.Optional[str]]:
if not self.config_key:
return None, None, None
section_name, key = self.config_key.split('/', 1)
if section_name.endswith(':'):
section_name += 'NAME'
if self.choices:
value = '|'.join(self.choices)
else:
value = self.metavar or self.argparse_dest().upper()
return (section_name, key, value)
CONFIG_ITEMS = [
ConfigItem(
'--version',
action = 'version',
version = f'ukify {__version__}',
),
ConfigItem(
'--summary',
help = 'print parsed config and exit',
action = 'store_true',
),
ConfigItem(
'linux',
metavar = 'LINUX',
type = pathlib.Path,
nargs = '?',
help = 'vmlinuz file [.linux section]',
config_key = 'UKI/Linux',
),
ConfigItem(
'initrd',
metavar = 'INITRD…',
type = pathlib.Path,
nargs = '*',
help = 'initrd files [.initrd section]',
config_key = 'UKI/Initrd',
config_push = ConfigItem.config_list_prepend,
),
ConfigItem(
('--config', '-c'),
metavar = 'PATH',
help = 'configuration file',
),
ConfigItem(
'--cmdline',
metavar = 'TEXT|@PATH',
help = 'kernel command line [.cmdline section]',
config_key = 'UKI/Cmdline',
),
ConfigItem(
'--os-release',
metavar = 'TEXT|@PATH',
help = 'path to os-release file [.osrel section]',
config_key = 'UKI/OSRelease',
),
ConfigItem(
'--devicetree',
metavar = 'PATH',
type = pathlib.Path,
help = 'Device Tree file [.dtb section]',
config_key = 'UKI/DeviceTree',
),
ConfigItem(
'--splash',
metavar = 'BMP',
type = pathlib.Path,
help = 'splash image bitmap file [.splash section]',
config_key = 'UKI/Splash',
),
ConfigItem(
'--pcrpkey',
metavar = 'KEY',
type = pathlib.Path,
help = 'embedded public key to seal secrets to [.pcrpkey section]',
config_key = 'UKI/PCRPKey',
),
ConfigItem(
'--uname',
metavar='VERSION',
help='"uname -r" information [.uname section]',
config_key = 'UKI/Uname',
),
ConfigItem(
'--efi-arch',
metavar = 'ARCH',
choices = ('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
help = 'target EFI architecture',
config_key = 'UKI/EFIArch',
),
ConfigItem(
'--stub',
type = pathlib.Path,
help = 'path to the sd-stub file [.text,.data,… sections]',
config_key = 'UKI/Stub',
),
ConfigItem(
'--section',
dest = 'sections',
metavar = 'NAME:TEXT|@PATH',
type = Section.parse_arg,
action = 'append',
default = [],
help = 'additional section as name and contents [NAME section]',
),
ConfigItem(
'--pcr-banks',
metavar = 'BANK…',
type = parse_banks,
config_key = 'UKI/PCRBanks',
),
ConfigItem(
'--signing-engine',
metavar = 'ENGINE',
help = 'OpenSSL engine to use for signing',
config_key = 'UKI/SigningEngine',
),
ConfigItem(
'--secureboot-private-key',
dest = 'sb_key',
help = 'path to key file or engine-specific designation for SB signing',
config_key = 'UKI/SecureBootPrivateKey',
),
ConfigItem(
'--secureboot-certificate',
dest = 'sb_cert',
help = 'path to certificate file or engine-specific designation for SB signing',
config_key = 'UKI/SecureBootCertificate',
),
ConfigItem(
'--sign-kernel',
action = argparse.BooleanOptionalAction,
help = 'Sign the embedded kernel',
config_key = 'UKI/SignKernel',
),
ConfigItem(
'--pcr-private-key',
dest = 'pcr_private_keys',
metavar = 'PATH',
type = pathlib.Path,
action = 'append',
help = 'private part of the keypair for signing PCR signatures',
config_key = 'PCRSignature:/PCRPrivateKey',
config_push = ConfigItem.config_set_group,
),
ConfigItem(
'--pcr-public-key',
dest = 'pcr_public_keys',
metavar = 'PATH',
type = pathlib.Path,
action = 'append',
help = 'public part of the keypair for signing PCR signatures',
config_key = 'PCRSignature:/PCRPublicKey',
config_push = ConfigItem.config_set_group,
),
ConfigItem(
'--phases',
dest = 'phase_path_groups',
metavar = 'PHASE-PATH…',
type = parse_phase_paths,
action = 'append',
help = 'phase-paths to create signatures for',
config_key = 'PCRSignature:/Phases',
config_push = ConfigItem.config_set_group,
),
ConfigItem(
'--tools',
type = pathlib.Path,
action = 'append',
help = 'Directories to search for tools (systemd-measure, …)',
),
ConfigItem(
('--output', '-o'),
type = pathlib.Path,
help = 'output file path',
),
ConfigItem(
'--measure',
action = argparse.BooleanOptionalAction,
help = 'print systemd-measure output for the UKI',
),
]
CONFIGFILE_ITEMS = { item.config_key:item
for item in CONFIG_ITEMS
if item.config_key }
def apply_config(namespace, filename=None):
if filename is None:
filename = namespace.config
if filename is None:
return
# Fill in ._groups based on --pcr-public-key=, --pcr-private-key=, and --phases=.
assert '_groups' not in namespace
n_pcr_priv = len(namespace.pcr_private_keys or ())
namespace._groups = list(range(n_pcr_priv))
cp = configparser.ConfigParser(
comment_prefixes='#',
inline_comment_prefixes='#',
delimiters='=',
empty_lines_in_values=False,
interpolation=None,
strict=False)
# Do not make keys lowercase
cp.optionxform = lambda option: option
cp.read(filename)
for section_name, section in cp.items():
idx = section_name.find(':')
if idx >= 0:
section_name, group = section_name[:idx+1], section_name[idx+1:]
if not section_name or not group:
raise ValueError('Section name components cannot be empty')
if ':' in group:
raise ValueError('Section name cannot contain more than one ":"')
else:
group = None
for key, value in section.items():
if item := CONFIGFILE_ITEMS.get(f'{section_name}/{key}'):
item.apply_config(namespace, section_name, group, key, value)
else:
print(f'Unknown config setting [{section_name}] {key}=')
def config_example():
prev_section = None
for item in CONFIG_ITEMS:
section, key, value = item.config_example()
if section:
if prev_section != section:
if prev_section:
yield ''
yield f'[{section}]'
prev_section = section
yield f'{key} = {value}'
def create_parser():
p = argparse.ArgumentParser(
description='Build and sign Unified Kernel Images',
allow_abbrev=False,
usage='''\
ukify [options] [LINUX INITRD]
ukify -h | --help
''')
''',
epilog='\n '.join(('config file:', *config_example())),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
for item in CONFIG_ITEMS:
item.add_to(p)
# Suppress printing of usage synopsis on errors
p.error = lambda message: p.exit(2, f'{p.prog}: error: {message}\n')
p.add_argument('linux',
metavar='LINUX',
type=pathlib.Path,
nargs="?",
help='vmlinuz file [.linux section]')
p.add_argument('initrd',
metavar='INITRD…',
type=pathlib.Path,
nargs='*',
help='initrd files [.initrd section]')
return p
p.add_argument('--cmdline',
metavar='TEXT|@PATH',
help='kernel command line [.cmdline section]')
p.add_argument('--os-release',
metavar='TEXT|@PATH',
help='path to os-release file [.osrel section]')
p.add_argument('--devicetree',
metavar='PATH',
type=pathlib.Path,
help='Device Tree file [.dtb section]')
p.add_argument('--splash',
metavar='BMP',
type=pathlib.Path,
help='splash image bitmap file [.splash section]')
p.add_argument('--pcrpkey',
metavar='KEY',
type=pathlib.Path,
help='embedded public key to seal secrets to [.pcrpkey section]')
p.add_argument('--uname',
metavar='VERSION',
help='"uname -r" information [.uname section]')
p.add_argument('--efi-arch',
metavar='ARCH',
choices=('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
help='target EFI architecture')
p.add_argument('--stub',
type=pathlib.Path,
help='path to the sd-stub file [.text,.data,… sections]')
p.add_argument('--section',
dest='sections',
metavar='NAME:TEXT|@PATH',
type=Section.parse_arg,
action='append',
default=[],
help='additional section as name and contents [NAME section]')
p.add_argument('--pcr-private-key',
dest='pcr_private_keys',
metavar='PATH',
type=pathlib.Path,
action='append',
help='private part of the keypair for signing PCR signatures')
p.add_argument('--pcr-public-key',
dest='pcr_public_keys',
metavar='PATH',
type=pathlib.Path,
action='append',
help='public part of the keypair for signing PCR signatures')
p.add_argument('--phases',
dest='phase_path_groups',
metavar='PHASE-PATH…',
type=parse_phase_paths,
action='append',
help='phase-paths to create signatures for')
p.add_argument('--pcr-banks',
metavar='BANK…',
type=parse_banks)
p.add_argument('--signing-engine',
metavar='ENGINE',
help='OpenSSL engine to use for signing')
p.add_argument('--secureboot-private-key',
dest='sb_key',
help='path to key file or engine-specific designation for SB signing')
p.add_argument('--secureboot-certificate',
dest='sb_cert',
help='path to certificate file or engine-specific designation for SB signing')
p.add_argument('--sign-kernel',
action=argparse.BooleanOptionalAction,
help='Sign the embedded kernel')
p.add_argument('--tools',
type=pathlib.Path,
action='append',
help='Directories to search for tools (systemd-measure, ...)')
p.add_argument('--output', '-o',
type=pathlib.Path,
help='output file path')
p.add_argument('--measure',
action=argparse.BooleanOptionalAction,
help='print systemd-measure output for the UKI')
p.add_argument('--version',
action='version',
version=f'ukify {__version__}')
opts = p.parse_args(args)
if opts.linux is not None:
path_is_readable(opts.linux)
for initrd in opts.initrd or ():
path_is_readable(initrd)
path_is_readable(opts.devicetree)
path_is_readable(opts.pcrpkey)
for key in opts.pcr_private_keys or ():
path_is_readable(key)
for key in opts.pcr_public_keys or ():
path_is_readable(key)
def finalize_options(opts):
if opts.cmdline and opts.cmdline.startswith('@'):
opts.cmdline = path_is_readable(opts.cmdline[1:])
opts.cmdline = pathlib.Path(opts.cmdline[1:])
elif opts.cmdline:
# Drop whitespace from the commandline. If we're reading from a file,
# we copy the contents verbatim. But configuration specified on the commandline
# or in the config file may contain additional whitespace that has no meaning.
opts.cmdline = ' '.join(opts.cmdline.split())
if opts.os_release is not None and opts.os_release.startswith('@'):
opts.os_release = path_is_readable(opts.os_release[1:])
elif opts.os_release is None and opts.linux is not None:
if opts.os_release and opts.os_release.startswith('@'):
opts.os_release = pathlib.Path(opts.os_release[1:])
elif not opts.os_release and opts.linux:
p = pathlib.Path('/etc/os-release')
if not p.exists():
p = path_is_readable('/usr/lib/os-release')
p = pathlib.Path('/usr/lib/os-release')
opts.os_release = p
if opts.efi_arch is None:
opts.efi_arch = guess_efi_arch()
if opts.stub is None:
opts.stub = path_is_readable(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
opts.stub = pathlib.Path(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
if opts.signing_engine is None:
opts.sb_key = path_is_readable(opts.sb_key) if opts.sb_key else None
opts.sb_cert = path_is_readable(opts.sb_cert) if opts.sb_cert else None
if opts.sb_key:
opts.sb_key = pathlib.Path(opts.sb_key)
if opts.sb_cert:
opts.sb_cert = pathlib.Path(opts.sb_cert)
if bool(opts.sb_key) ^ bool(opts.sb_cert):
raise ValueError('--secureboot-private-key= and --secureboot-certificate= must be specified together')
@ -826,14 +1074,6 @@ ukify [options…] [LINUX INITRD…]
if opts.sign_kernel and not opts.sb_key:
raise ValueError('--sign-kernel requires --secureboot-private-key= and --secureboot-certificate= to be specified')
n_pcr_pub = None if opts.pcr_public_keys is None else len(opts.pcr_public_keys)
n_pcr_priv = None if opts.pcr_private_keys is None else len(opts.pcr_private_keys)
n_phase_path_groups = None if opts.phase_path_groups is None else len(opts.phase_path_groups)
if n_pcr_pub is not None and n_pcr_pub != n_pcr_priv:
raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
if n_phase_path_groups is not None and n_phase_path_groups != n_pcr_priv:
raise ValueError('--phases= specifications must match --pcr-private-key=')
if opts.output is None:
if opts.linux is None:
raise ValueError('--output= must be specified when building a PE addon')
@ -843,6 +1083,30 @@ ukify [options…] [LINUX INITRD…]
for section in opts.sections:
section.check_name()
if opts.summary:
# TODO: replace pprint() with some fancy formatting.
pprint.pprint(vars(opts))
sys.exit()
def parse_args(args=None):
p = create_parser()
opts = p.parse_args(args)
# Check that --pcr-public-key=, --pcr-private-key=, and --phases=
# have either the same number of arguments are are not specified at all.
n_pcr_pub = None if opts.pcr_public_keys is None else len(opts.pcr_public_keys)
n_pcr_priv = None if opts.pcr_private_keys is None else len(opts.pcr_private_keys)
n_phase_path_groups = None if opts.phase_path_groups is None else len(opts.phase_path_groups)
if n_pcr_pub is not None and n_pcr_pub != n_pcr_priv:
raise ValueError('--pcr-public-key= specifications must match --pcr-private-key=')
if n_phase_path_groups is not None and n_phase_path_groups != n_pcr_priv:
raise ValueError('--phases= specifications must match --pcr-private-key=')
apply_config(opts)
finalize_options(opts)
return opts