From: Daan De Meyer Date: Mon, 3 Apr 2023 09:51:33 +0000 (+0200) Subject: Rework configuration parsing X-Git-Tag: v15~268^2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=refs%2Fpull%2F1420%2Fhead;p=thirdparty%2Fmkosi.git Rework configuration parsing Instead of messing with the internals of argparse, let's implement proper configuration file parsing. - Parsing classes and functions are located in a new file config.py - Configuration settings are split up from CLI settings in a list of the new ConfigSetting dataclass - The CLI options for configuration settings now share the same argparse setting which simply delegates parsing to the corresponding configuration setting parser. - We add support for [Match] sections to enable conditionally including configuration files. Currently Match support is implemented for Distribution= and Release=. - Configuration file searching is reworked. In mkosi.conf.d/, we parse all files ending with ".conf" and directories. If a directory is parsed, we parse mkosi.conf, mkosi.conf.d/ and all mkosi specific paths in it. All paths are interpreted relative to the directory that we're parsing. --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0c24314a..69c40ba63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,7 +188,6 @@ jobs: Format=${{ matrix.format }} Bootable=yes KernelCommandLine=systemd.unit=mkosi-check-and-shutdown.service - !quiet systemd.log_target=console systemd.default_standard_output=journal+console @@ -211,7 +210,7 @@ jobs: run: | tee mkosi.conf.d/initrd.conf <<- EOF [Distribution] - Initrd=initrd.zst + Initrds=initrd.zst EOF - name: Configure EPEL diff --git a/mkosi.md b/mkosi.md index f499ac8c0..9d830e123 100644 --- a/mkosi.md +++ b/mkosi.md @@ -188,7 +188,9 @@ a boolean argument: either "1", "yes", or "true" to enable, or "0", : The distribution to install in the image. Takes one of the following arguments: `fedora`, `debian`, `ubuntu`, `arch`, `opensuse`, `mageia`, `centos`, `openmandriva`, `rocky`, and `alma`. If not specified, - defaults to the distribution of the host. + defaults to the distribution of the host. Whenever a distribution is + assigned, the release is reset to the default release configured + for that distribution. `Release=`, `--release=`, `-r` diff --git a/mkosi/__init__.py b/mkosi/__init__.py index f8b717a93..736b3e47f 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -2,7 +2,6 @@ import argparse import base64 -import configparser import contextlib import crypt import dataclasses @@ -14,7 +13,6 @@ import itertools import json import os import platform -import re import resource import shutil import string @@ -22,20 +20,10 @@ import subprocess import sys import tempfile import uuid -from collections.abc import Iterable, Iterator, Sequence +from collections.abc import Iterator, Sequence from pathlib import Path from textwrap import dedent, wrap -from typing import ( - Any, - Callable, - ContextManager, - NoReturn, - Optional, - TextIO, - TypeVar, - Union, - cast, -) +from typing import Callable, ContextManager, Optional, TextIO, TypeVar, Union, cast from mkosi.backend import ( Distribution, @@ -54,6 +42,25 @@ from mkosi.backend import ( should_compress_output, tmp_dir, ) +from mkosi.config import ( + MkosiConfigParser, + MkosiConfigSetting, + config_make_action, + config_make_enum_matcher, + config_make_enum_parser, + config_make_list_parser, + config_make_path_parser, + config_match_string, + config_parse_base_packages, + config_parse_boolean, + config_parse_compression, + config_parse_distribution, + config_parse_feature, + config_parse_script, + config_parse_string, + make_enum_parser, + parse_source_target_paths, +) from mkosi.install import ( add_dropin_config_from_resource, copy_path, @@ -336,7 +343,7 @@ def clean_package_manager_metadata(state: MkosiState) -> None: package manager is present in the image. """ - assert state.config.clean_package_metadata in (False, True, 'auto') + assert state.config.clean_package_metadata in (False, True, None) if state.config.clean_package_metadata is False or state.for_cache: return @@ -568,6 +575,9 @@ def install_boot_loader(state: MkosiState) -> None: return if state.config.secure_boot: + assert state.config.secure_boot_key + assert state.config.secure_boot_certificate + p = state.root / "usr/lib/systemd/boot/efi" with complete_step("Signing systemd-boot binaries…"): @@ -589,6 +599,9 @@ def install_boot_loader(state: MkosiState) -> None: run(["bootctl", "install", "--root", state.root], env={"SYSTEMD_ESP_PATH": "/boot"}) if state.config.secure_boot: + assert state.config.secure_boot_key + assert state.config.secure_boot_certificate + with complete_step("Setting up secure boot auto-enrollment…"): keys = state.root / "boot/loader/keys/auto" keys.mkdir(parents=True, exist_ok=True) @@ -845,6 +858,9 @@ def install_unified_kernel(state: MkosiState, roothash: Optional[str]) -> None: cmd += ["--tools", p] if state.config.secure_boot: + assert state.config.secure_boot_key + assert state.config.secure_boot_certificate + cmd += [ "--secureboot-private-key", state.config.secure_boot_key, "--secureboot-certificate", state.config.secure_boot_certificate, @@ -1022,170 +1038,6 @@ def remove_duplicates(items: list[T]) -> list[T]: return list({x: None for x in items}) -class ListAction(argparse.Action): - delimiter: str - deduplicate: bool = True - - def __init__(self, *args: Any, choices: Optional[Iterable[Any]] = None, **kwargs: Any) -> None: - self.list_choices = choices - # mypy doesn't like the following call due to https://github.com/python/mypy/issues/6799, - # so let's, temporarily, ignore the error - super().__init__(choices=choices, *args, **kwargs) # type: ignore[misc] - - def __call__( - self, # These type-hints are copied from argparse.pyi - parser: argparse.ArgumentParser, - namespace: argparse.Namespace, - values: Union[str, Sequence[Any], None], - option_string: Optional[str] = None, - ) -> None: - ary = getattr(namespace, self.dest) - if ary is None: - ary = [] - - if isinstance(values, (str, Path)): - # Save the actual type so we can restore it later after processing the argument - t = type(values) - values = str(values) - # Support list syntax for comma separated lists as well - if self.delimiter == "," and values.startswith("[") and values.endswith("]"): - values = values[1:-1] - - # Make sure delimiters between quotes are ignored. - # Inspired by https://stackoverflow.com/a/2787979. - values = [t(x.strip()) for x in re.split(f"""{self.delimiter}(?=(?:[^'"]|'[^']*'|"[^"]*")*$)""", values) if x] - - if isinstance(values, list): - for x in values: - if self.list_choices is not None and x not in self.list_choices: - raise ValueError(f"Unknown value {x!r}") - - # Remove ! prefixed list entries from list. !* removes all entries. This works for strings only now. - if x == "!*": - ary = [] - elif isinstance(x, str) and x.startswith("!"): - if x[1:] in ary: - ary.remove(x[1:]) - else: - ary.append(x) - else: - ary.append(values) - - if self.deduplicate: - ary = remove_duplicates(ary) - setattr(namespace, self.dest, ary) - - -class CommaDelimitedListAction(ListAction): - delimiter = "," - - -class ColonDelimitedListAction(ListAction): - delimiter = ":" - - -class SpaceDelimitedListAction(ListAction): - delimiter = " " - - -class RepeatableSpaceDelimitedListAction(SpaceDelimitedListAction): - deduplicate = False - - -class BooleanAction(argparse.Action): - """Parse boolean command line arguments - - The argument may be added more than once. The argument may be set explicitly (--foo yes) - or implicitly --foo. If the parameter name starts with "not-" or "without-" the value gets - inverted. - """ - - def __init__( - self, # These type-hints are copied from argparse.pyi - option_strings: Sequence[str], - dest: str, - nargs: Optional[Union[int, str]] = None, - const: Any = True, - default: Any = False, - **kwargs: Any, - ) -> None: - if nargs is not None: - raise ValueError("nargs not allowed") - super().__init__(option_strings, dest, nargs="?", const=const, default=default, **kwargs) - - def __call__( - self, # These type-hints are copied from argparse.pyi - parser: argparse.ArgumentParser, - namespace: argparse.Namespace, - values: Union[str, Sequence[Any], None, bool], - option_string: Optional[str] = None, - ) -> None: - if isinstance(values, str): - try: - new_value = parse_boolean(values) - except ValueError as exp: - raise argparse.ArgumentError(self, str(exp)) - elif isinstance(values, bool): # Assign const - new_value = values - else: - raise argparse.ArgumentError(self, f"Invalid argument for {option_string}: {values}") - - # invert the value if the argument name starts with "not" or "without" - for option in self.option_strings: - if option[2:].startswith("not-") or option[2:].startswith("without-"): - new_value = not new_value - break - - setattr(namespace, self.dest, new_value) - - -class CleanPackageMetadataAction(BooleanAction): - def __call__( - self, - parser: argparse.ArgumentParser, - namespace: argparse.Namespace, - values: Union[str, Sequence[Any], None, bool], - option_string: Optional[str] = None, - ) -> None: - - if isinstance(values, str) and values == "auto": - setattr(namespace, self.dest, "auto") - else: - super().__call__(parser, namespace, values, option_string) - - -def parse_sign_expected_pcr(value: Union[bool, str]) -> bool: - if isinstance(value, bool): - return value - - if value == "auto": - return bool(shutil.which('systemd-measure')) - - val = parse_boolean(value) - if val: - if not shutil.which('systemd-measure'): - die("Couldn't find systemd-measure binary. It is needed for the --sign-expected-pcr option.") - - return val - - -class SignExpectedPcrAction(BooleanAction): - def __call__( - self, - parser: argparse.ArgumentParser, - namespace: argparse.Namespace, - values: Union[str, Sequence[Any], None, bool], - option_string: Optional[str] = None, - ) -> None: - if values is None: - parsed = False - elif isinstance(values, bool) or isinstance(values, str): - parsed = parse_sign_expected_pcr(values) - else: - raise argparse.ArgumentError(self, f"Invalid argument for {option_string}: {values}") - setattr(namespace, self.dest, parsed) - - class CustomHelpFormatter(argparse.HelpFormatter): def _format_action_invocation(self, action: argparse.Action) -> str: if not action.option_strings or action.nargs == 0: @@ -1206,138 +1058,6 @@ class CustomHelpFormatter(argparse.HelpFormatter): subsequent_indent=subindent) for line in lines) -class ArgumentParserMkosi(argparse.ArgumentParser): - """ArgumentParser with support for mkosi configuration file(s) - - This derived class adds a simple ini file parser to python's ArgumentParser features. - Each line of the ini file is converted to a command line argument. Example: - "FooBar=Hello_World" in the ini file appends "--foo-bar Hello_World" to sys.argv. - - Command line arguments starting with - or --are considered as regular arguments. Arguments - starting with @ are considered as files which are fed to the ini file parser implemented - in this class. - """ - - # Mapping of parameters supported in config files but not as command line arguments. - SPECIAL_MKOSI_DEFAULT_PARAMS = { - "OutputDirectory": "--output-dir", - "WorkspaceDirectory": "--workspace-dir", - "CacheDirectory": "--cache-dir", - "RepartDirectory": "--repart-dir", - "BuildDirectory": "--build-dir", - "NSpawnSettings": "--settings", - "CheckSum": "--checksum", - "Packages": "--package", - "RemovePackages": "--remove-package", - "ExtraTrees": "--extra-tree", - "SkeletonTrees": "--skeleton-tree", - "BuildPackages": "--build-package", - "PostInstallationScript": "--postinst-script", - "TarStripSELinuxContext": "--tar-strip-selinux-context", - "SignExpectedPCR": "--sign-expected-pcr", - "RepositoryDirectories": "--repo-dir", - "Credentials": "--credential", - } - - def __init__(self, *kargs: Any, **kwargs: Any) -> None: - self._ini_file_section = "" - self._ini_file_key = "" # multi line list processing - self._ini_file_list_mode = False - - # we need to suppress mypy here: https://github.com/python/mypy/issues/6799 - super().__init__(*kargs, - # Add config files to be parsed: - fromfile_prefix_chars='@', - formatter_class=CustomHelpFormatter, - # Tweak defaults: - allow_abbrev=False, - # Pass through the other options: - **kwargs, - ) # type: ignore - - @staticmethod - def _camel_to_arg(camel: str) -> str: - s1 = re.sub("(.)([A-Z][a-z]+)", r"\1-\2", camel) - return re.sub("([a-z0-9])([A-Z])", r"\1-\2", s1).lower() - - @classmethod - def _ini_key_to_cli_arg(cls, key: str) -> str: - return cls.SPECIAL_MKOSI_DEFAULT_PARAMS.get(key) or ("--" + cls._camel_to_arg(key)) - - def _read_args_from_files(self, arg_strings: list[str]) -> list[str]: - """Convert @-prefixed command line arguments with corresponding file content - - Regular arguments are just returned. Arguments prefixed with @ are considered - configuration file paths. The settings of each file are parsed and returned as - command line arguments. - Example: - The following mkosi config is loaded. - [Distribution] - Distribution=fedora - - mkosi is called like: mkosi -p httpd - - arg_strings: ['@mkosi.conf', '-p', 'httpd'] - return value: ['--distribution', 'fedora', '-p', 'httpd'] - """ - - # expand arguments referencing files - new_arg_strings = [] - for arg_string in arg_strings: - # for regular arguments, just add them back into the list - if not arg_string.startswith('@'): - new_arg_strings.append(arg_string) - continue - # replace arguments referencing files with the file content - try: - # This used to use configparser.ConfigParser before, but - # ConfigParser's interpolation clashes with systemd style - # specifier, e.g. %u for user, since both use % as a sigil. - config = configparser.RawConfigParser(delimiters="=", inline_comment_prefixes=("#",)) - config.optionxform = str # type: ignore - with open(arg_string[1:]) as args_file: - config.read_file(args_file) - - # Rename old [Packages] section to [Content] - if config.has_section("Packages") and not config.has_section("Content"): - config.read_dict({"Content": dict(config.items("Packages"))}) - config.remove_section("Packages") - - for section in config.sections(): - for key, value in config.items(section): - cli_arg = self._ini_key_to_cli_arg(key) - - # \n in value strings is forwarded. Depending on the action type, \n is considered as a delimiter or needs to be replaced by a ' ' - for action in self._actions: - if cli_arg in action.option_strings: - if isinstance(action, ListAction): - value = value.replace(os.linesep, action.delimiter) - new_arg_strings.append(f"{cli_arg}={value}") - except OSError as e: - self.error(str(e)) - # return the modified argument list - return new_arg_strings - - def error(self, message: str) -> NoReturn: - # This is a copy of super's method but with self.print_usage() removed - self.exit(2, f'{self.prog}: error: {message}\n') - - -COMPRESSION_ALGORITHMS = "zlib", "lzo", "zstd", "lz4", "xz" - - -def parse_compression(value: str) -> Union[str, bool]: - if value in COMPRESSION_ALGORITHMS: - return value - return parse_boolean(value) - - -def parse_base_packages(value: str) -> Union[str, bool]: - if value == "conditional": - return value - return parse_boolean(value) - - USAGE = """ mkosi [options...] {b}summary{e} mkosi [options...] {b}build{e} [script parameters...] @@ -1355,19 +1075,409 @@ USAGE = """ """.format(b=MkosiPrinter.bold, e=MkosiPrinter.reset) -def parse_source_target_paths(value: str) -> tuple[Path, Optional[Path]]: - src, _, target = value.partition(':') - if target and not Path(target).absolute(): - die("Target path must be absolute") - return Path(src), Path(target) if target else None +SETTINGS = ( + MkosiConfigSetting( + dest="distribution", + section="Distribution", + parse=config_parse_distribution, + match=config_make_enum_matcher(Distribution), + default=detect_distribution()[0], + ), + MkosiConfigSetting( + dest="release", + section="Distribution", + parse=config_parse_string, + match=config_match_string, + default=detect_distribution()[1], + ), + MkosiConfigSetting( + dest="architecture", + section="Distribution", + default=platform.machine(), + ), + MkosiConfigSetting( + dest="mirror", + section="Distribution", + ), + MkosiConfigSetting( + dest="local_mirror", + section="Distribution", + ), + MkosiConfigSetting( + dest="repository_key_check", + section="Distribution", + default=True, + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="repositories", + section="Distribution", + parse=config_make_list_parser(delimiter=","), + ), + MkosiConfigSetting( + dest="repo_dirs", + name="RepositoryDirectories", + section="Distribution", + parse=config_make_list_parser(delimiter=",", parse=Path), + paths=("mkosi.reposdir",), + ), + MkosiConfigSetting( + dest="output_format", + name="Format", + section="Output", + parse=config_make_enum_parser(OutputFormat), + default=OutputFormat.disk, + ), + MkosiConfigSetting( + dest="manifest_format", + section="Output", + parse=config_make_list_parser(delimiter=",", parse=make_enum_parser(ManifestFormat)), + default=[ManifestFormat.json], + ), + MkosiConfigSetting( + dest="output", + section="Output", + parse=config_make_path_parser(required=False), + ), + MkosiConfigSetting( + dest="output_dir", + name="OutputDirectory", + section="Output", + parse=config_make_path_parser(required=False), + paths=("mkosi.output",), + ), + MkosiConfigSetting( + dest="workspace_dir", + name="WorkspaceDirectory", + section="Output", + parse=config_make_path_parser(required=False), + paths=("mkosi.workspace",), + ), + MkosiConfigSetting( + dest="bootable", + section="Output", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="kernel_command_line", + section="Output", + parse=config_make_list_parser(delimiter=" "), + ), + MkosiConfigSetting( + dest="secure_boot", + section="Output", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="secure_boot_key", + section="Output", + parse=config_make_path_parser(required=False), + paths=("mkosi.secure-boot.key",), + ), + MkosiConfigSetting( + dest="secure_boot_certificate", + section="Output", + parse=config_make_path_parser(required=False), + paths=("mkosi.secure-boot.crt",), + ), + MkosiConfigSetting( + dest="secure_boot_valid_days", + section="Output", + default="730", + ), + MkosiConfigSetting( + dest="secure_boot_common_name", + section="Output", + default="mkosi of %u", + ), + MkosiConfigSetting( + dest="sign_expected_pcr", + section="Output", + parse=config_parse_feature, + ), + MkosiConfigSetting( + dest="passphrase", + section="Output", + parse=config_make_path_parser(required=False), + paths=("mkosi.passphrase",), + ), + MkosiConfigSetting( + dest="compress_output", + section="Output", + parse=config_parse_compression, + ), + MkosiConfigSetting( + dest="hostname", + section="Output", + ), + MkosiConfigSetting( + dest="image_version", + section="Output", + ), + MkosiConfigSetting( + dest="image_id", + section="Output", + ), + MkosiConfigSetting( + dest="tar_strip_selinux_context", + section="Output", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="incremental", + section="Output", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="cache_initrd", + section="Output", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="split_artifacts", + section="Output", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="repart_dir", + name="RepartDirectory", + section="Output", + parse=config_make_path_parser(required=True), + paths=("mkosi.repart",), + ), + MkosiConfigSetting( + dest="initrds", + section="Output", + parse=config_make_list_parser(delimiter=",", parse=Path), + ), + MkosiConfigSetting( + dest="base_packages", + section="Content", + parse=config_parse_base_packages, + default=True, + ), + MkosiConfigSetting( + dest="packages", + section="Content", + parse=config_make_list_parser(delimiter=","), + ), + MkosiConfigSetting( + dest="remove_packages", + section="Content", + parse=config_make_list_parser(delimiter=","), + ), + MkosiConfigSetting( + dest="with_docs", + section="Content", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="with_tests", + section="Content", + parse=config_parse_boolean, + default=True, + ), + MkosiConfigSetting( + dest="password", + section="Content", + ), + MkosiConfigSetting( + dest="password_is_hashed", + section="Content", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="autologin", + section="Content", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="cache_dir", + name="CacheDirectory", + section="Content", + parse=config_make_path_parser(required=False), + paths=("mkosi.cache",), + ), + MkosiConfigSetting( + dest="extra_trees", + section="Content", + parse=config_make_list_parser(delimiter=",", parse=parse_source_target_paths), + paths=("mkosi.extra", "mkosi.extra.tar"), + ), + MkosiConfigSetting( + dest="skeleton_trees", + section="Content", + parse=config_make_list_parser(delimiter=",", parse=parse_source_target_paths), + paths=("mkosi.skeleton", "mkosi.skeleton.tar"), + ), + MkosiConfigSetting( + dest="clean_package_metadata", + section="Content", + parse=config_parse_feature, + ), + MkosiConfigSetting( + dest="remove_files", + section="Content", + parse=config_make_list_parser(delimiter=","), + ), + MkosiConfigSetting( + dest="environment", + section="Content", + parse=config_make_list_parser(delimiter=" "), + ), + MkosiConfigSetting( + dest="build_sources", + section="Content", + parse=config_make_path_parser(required=True), + default=".", + ), + MkosiConfigSetting( + dest="build_dir", + name="BuildDirectory", + section="Content", + parse=config_make_path_parser(required=False), + paths=("mkosi.builddir",), + ), + MkosiConfigSetting( + dest="install_dir", + name="InstallDirectory", + section="Content", + parse=config_make_path_parser(required=False), + paths=("mkosi.installdir",), + ), + MkosiConfigSetting( + dest="build_packages", + section="Content", + parse=config_make_list_parser(delimiter=","), + ), + MkosiConfigSetting( + dest="build_script", + section="Content", + parse=config_parse_script, + paths=("mkosi.build",), + ), + MkosiConfigSetting( + dest="prepare_script", + section="Content", + parse=config_parse_script, + paths=("mkosi.prepare",), + ), + MkosiConfigSetting( + dest="postinst_script", + name="PostInstallationScript", + section="Content", + parse=config_parse_script, + paths=("mkosi.postinst",), + ), + MkosiConfigSetting( + dest="finalize_script", + section="Content", + parse=config_parse_script, + paths=("mkosi.finalize",), + ), + MkosiConfigSetting( + dest="with_network", + section="Content", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="cache_only", + section="Content", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="nspawn_settings", + name="NSpawnSettings", + section="Content", + parse=config_make_path_parser(required=True), + paths=("mkosi.nspawn",), + ), + MkosiConfigSetting( + dest="base_image", + section="Content", + parse=config_make_path_parser(required=True), + ), + MkosiConfigSetting( + dest="checksum", + section="Validation", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="sign", + section="Validation", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="key", + section="Validation", + ), + MkosiConfigSetting( + dest="extra_search_paths", + section="Host", + parse=config_make_list_parser(delimiter=",", parse=Path), + ), + MkosiConfigSetting( + dest="qemu_gui", + section="Host", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="qemu_smp", + section="Host", + default="1", + ), + MkosiConfigSetting( + dest="qemu_mem", + section="Host", + default="2G", + ), + MkosiConfigSetting( + dest="qemu_kvm", + section="Host", + parse=config_parse_feature, + ), + MkosiConfigSetting( + dest="qemu_args", + section="Host", + parse=config_make_list_parser(delimiter=" "), + ), + MkosiConfigSetting( + dest="ephemeral", + section="Host", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="ssh", + section="Host", + parse=config_parse_boolean, + ), + MkosiConfigSetting( + dest="credentials", + section="Host", + parse=config_make_list_parser(delimiter=" "), + ), + MkosiConfigSetting( + dest="kernel_command_line_extra", + section="Host", + parse=config_make_list_parser(delimiter=" "), + ), + MkosiConfigSetting( + dest="acl", + section="Host", + parse=config_parse_boolean, + ), +) + +def create_argument_parser() -> argparse.ArgumentParser: + action = config_make_action(SETTINGS) -def create_parser() -> ArgumentParserMkosi: - parser = ArgumentParserMkosi( + parser = argparse.ArgumentParser( prog="mkosi", description="Build Bespoke OS Images", usage=USAGE, add_help=False, + allow_abbrev=False, ) parser.add_argument( @@ -1394,468 +1504,483 @@ def create_parser() -> ArgumentParserMkosi: help=argparse.SUPPRESS, ) parser.add_argument( - "-C", "--directory", - help="Change to specified directory before doing anything", - type=Path, - metavar="PATH", + "-f", "--force", + action="count", + dest="force", + default=0, + help="Remove existing image file before operation", ) parser.add_argument( - "--config", - dest="config_path", - help="Read configuration data from file", + "-C", "--directory", + help="Change to specified directory before doing anything", type=Path, metavar="PATH", ) parser.add_argument( "--debug", - action=CommaDelimitedListAction, - default=[], help="Turn on debugging output", + action="append", + default=[], ) + group = parser.add_argument_group("Distribution options") - group.add_argument("-d", "--distribution", choices=Distribution.__members__, help="Distribution to install") - group.add_argument("-r", "--release", help="Distribution release to install") - group.add_argument("--architecture", help="Override the architecture of installation", default=platform.machine()) - group.add_argument("-m", "--mirror", help="Distribution mirror to use") - group.add_argument("--local-mirror", help="Use a single local, flat and plain mirror to build the image", + group.add_argument( + "-d", "--distribution", + choices=Distribution.__members__, + help="Distribution to install", + action=action, + ) + group.add_argument( + "-r", "--release", + metavar="RELEASE", + help="Distribution release to install", + action=action, + ) + group.add_argument( + "--architecture", + metavar="ARCHITECTURE", + help="Override the architecture of installation", + action=action, + ) + group.add_argument( + "-m", "--mirror", + metavar="MIRROR", + help="Distribution mirror to use", + action=action, + ) + group.add_argument( + "--local-mirror", + help="Use a single local, flat and plain mirror to build the image", + action=action, ) group.add_argument( "--repository-key-check", metavar="BOOL", - action=BooleanAction, help="Controls signature and key checks on repositories", - default=True, + nargs="?", + action=action, ) - group.add_argument( "--repositories", metavar="REPOS", - action=CommaDelimitedListAction, - default=[], help="Repositories to use", + action=action, ) group.add_argument( "--repo-dir", - action=CommaDelimitedListAction, - default=[], metavar="PATH", - dest="repo_dirs", - type=Path, help="Specify a directory containing extra distribution specific repository files", + dest="repo_dirs", + action=action, ) group = parser.add_argument_group("Output options") group.add_argument( "-t", "--format", - dest="output_format", metavar="FORMAT", - choices=OutputFormat, - type=OutputFormat.from_string, + choices=OutputFormat.__members__, + dest="output_format", help="Output Format", + action=action, ) group.add_argument( "--manifest-format", metavar="FORMAT", - action=CommaDelimitedListAction, - type=cast(Callable[[str], ManifestFormat], ManifestFormat.parse_list), help="Manifest Format", + action=action, ) group.add_argument( "-o", "--output", - help="Output image path", - type=Path, metavar="PATH", + help="Output image path", + action=action, ) group.add_argument( "-O", "--output-dir", - help="Output root directory", - type=Path, metavar="DIR", + help="Output root directory", + action=action, ) group.add_argument( "--workspace-dir", - help="Workspace directory", - type=Path, metavar="DIR", - ) - group.add_argument( - "-f", "--force", - action="count", - dest="force", - default=0, - help="Remove existing image file before operation", + help="Workspace directory", + action=action, ) group.add_argument( "-b", "--bootable", metavar="BOOL", - action=BooleanAction, help="Make image bootable on EFI", + nargs="?", + action=action, ) group.add_argument( "--kernel-command-line", metavar="OPTIONS", - action=SpaceDelimitedListAction, - default=[], help="Set the kernel command line (only bootable images)", + action=action, ) group.add_argument( "--secure-boot", metavar="BOOL", - action=BooleanAction, help="Sign the resulting kernel/initrd image for UEFI SecureBoot", + nargs="?", + action=action, ) group.add_argument( "--secure-boot-key", - help="UEFI SecureBoot private key in PEM format", - type=Path, metavar="PATH", - default=Path("./mkosi.secure-boot.key"), + help="UEFI SecureBoot private key in PEM format", + action=action, ) group.add_argument( "--secure-boot-certificate", - help="UEFI SecureBoot certificate in X509 format", - type=Path, metavar="PATH", - default=Path("./mkosi.secure-boot.crt"), + help="UEFI SecureBoot certificate in X509 format", + action=action, ) group.add_argument( "--secure-boot-valid-days", - help="Number of days UEFI SecureBoot keys should be valid when generating keys", metavar="DAYS", - default="730", + help="Number of days UEFI SecureBoot keys should be valid when generating keys", + action=action, ) group.add_argument( "--secure-boot-common-name", - help="Template for the UEFI SecureBoot CN when generating keys", metavar="CN", - default="mkosi of %u", + help="Template for the UEFI SecureBoot CN when generating keys", + action=action, ) group.add_argument( "--sign-expected-pcr", - metavar="BOOL", - default="auto", - action=SignExpectedPcrAction, - type=parse_sign_expected_pcr, + metavar="FEATURE", help="Measure the components of the unified kernel image (UKI) and embed the PCR signature into the UKI", + action=action, + ) + group.add_argument( + "--passphrase", + metavar="PATH", + help="Path to a file containing the passphrase to use when LUKS encryption is selected", + action=action, ) group.add_argument( "--compress-output", - type=parse_compression, - nargs="?", metavar="ALG", help="Enable whole-output compression (with images or archives)", + nargs="?", + action=action, ) - group.add_argument("--hostname", help="Set hostname") - group.add_argument("--image-version", help="Set version for image") - group.add_argument("--image-id", help="Set ID for image") + group.add_argument("--hostname", help="Set hostname", action=action) + group.add_argument("--image-version", help="Set version for image", action=action) + group.add_argument("--image-id", help="Set ID for image", action=action) group.add_argument( "-B", "--auto-bump", metavar="BOOL", - action=BooleanAction, help="Automatically bump image version after building", + action=action, ) group.add_argument( "--tar-strip-selinux-context", metavar="BOOL", - action=BooleanAction, help="Do not include SELinux file context information in tar. Not compatible with bsdtar.", + nargs="?", + action=action, ) group.add_argument( "-i", "--incremental", metavar="BOOL", - action=BooleanAction, help="Make use of and generate intermediary cache images", + nargs="?", + action=action, ) group.add_argument( "--cache-initrd", metavar="BOOL", - action=BooleanAction, help="When using incremental mode, build the initrd in the cache image and don't rebuild it in the final image", + nargs="?", + action=action, ) group.add_argument( "--split-artifacts", metavar="BOOL", - action=BooleanAction, help="Generate split partitions", + nargs="?", + action=action, ) group.add_argument( "--repart-dir", metavar="PATH", help="Directory containing systemd-repart partition definitions", + action=action, ) group.add_argument( "--initrd", - dest="initrds", - action=CommaDelimitedListAction, - default=[], help="Add a user-provided initrd to image", - type=Path, metavar="PATH", + dest="initrds", + action=action, ) group = parser.add_argument_group("Content options") group.add_argument( "--base-packages", - type=parse_base_packages, - default=True, - help="Automatically inject basic packages in the system (systemd, kernel, …)", metavar="OPTION", + help="Automatically inject basic packages in the system (systemd, kernel, …)", + action=action, ) group.add_argument( - "-p", - "--package", - action=CommaDelimitedListAction, - dest="packages", - default=[], - help="Add an additional package to the OS image", + "-p", "--package", metavar="PACKAGE", + help="Add an additional package to the OS image", + dest="packages", + action=action, ) group.add_argument( "--remove-package", - action=CommaDelimitedListAction, - dest="remove_packages", - default=[], - help="Remove package from the image OS image after installation", metavar="PACKAGE", + help="Remove package from the image OS image after installation", + dest="remove_packages", + action=action, ) group.add_argument( "--with-docs", metavar="BOOL", - action=BooleanAction, help="Install documentation", + nargs="?", + action=action, ) group.add_argument( "-T", "--without-tests", - action=BooleanAction, - dest="with_tests", - default=True, help="Do not run tests as part of build script, if supported", + nargs="?", + const="no", + dest="with_tests", + action=action, ) - group.add_argument("--password", help="Set the root password") + group.add_argument("--password", help="Set the root password", action=action) group.add_argument( "--password-is-hashed", metavar="BOOL", - action=BooleanAction, help="Indicate that the root password has already been hashed", + nargs="?", + action=action, ) group.add_argument( "--autologin", metavar="BOOL", - action=BooleanAction, help="Enable root autologin", + nargs="?", + action=action, ) group.add_argument( "--cache-dir", - help="Package cache path", - type=Path, metavar="PATH", + help="Package cache path", + action=action, ) group.add_argument( "--extra-tree", - action=CommaDelimitedListAction, - dest="extra_trees", - default=[], - help="Copy an extra tree on top of image", - type=parse_source_target_paths, metavar="PATH", + help="Copy an extra tree on top of image", + dest="extra_trees", + action=action, ) group.add_argument( "--skeleton-tree", - action="append", - dest="skeleton_trees", - default=[], - help="Use a skeleton tree to bootstrap the image before installing anything", - type=parse_source_target_paths, metavar="PATH", + help="Use a skeleton tree to bootstrap the image before installing anything", + dest="skeleton_trees", + action=action, ) group.add_argument( "--clean-package-metadata", - action=CleanPackageMetadataAction, + metavar="FEATURE", help="Remove package manager database and other files", - default='auto', + action=action, ) group.add_argument( "--remove-files", - action=CommaDelimitedListAction, - default=[], - help="Remove files from built image", metavar="GLOB", + help="Remove files from built image", + action=action, ) group.add_argument( - "--environment", - "-E", - action=SpaceDelimitedListAction, - default=[], - help="Set an environment variable when running scripts", + "-E", "--environment", metavar="NAME[=VALUE]", + help="Set an environment variable when running scripts", + action=action, ) group.add_argument( "--build-sources", - help="Path for sources to build", metavar="PATH", - type=Path, + help="Path for sources to build", + action=action, ) group.add_argument( "--build-dir", - type=Path, metavar="PATH", help="Path to use as persistent build directory", + action=action, ) group.add_argument( "--install-dir", - help="Path to use as persistent install directory", - type=Path, metavar="PATH", + help="Path to use as persistent install directory", + action=action, ) group.add_argument( "--build-package", - action=CommaDelimitedListAction, - dest="build_packages", - default=[], - help="Additional packages needed for build script", metavar="PACKAGE", + help="Additional packages needed for build script", + dest="build_packages", + action=action, ) group.add_argument( "--build-script", - help="Build script to run inside image", - type=script_path, metavar="PATH", + help="Build script to run inside image", + action=action, ) group.add_argument( "--prepare-script", - help="Prepare script to run inside the image before it is cached", - type=script_path, metavar="PATH", + help="Prepare script to run inside the image before it is cached", + action=action, ) group.add_argument( "--postinst-script", - help="Postinstall script to run inside image", - type=script_path, metavar="PATH", + help="Postinstall script to run inside image", + action=action, ) group.add_argument( "--finalize-script", - help="Postinstall script to run outside image", - type=script_path, metavar="PATH", + help="Postinstall script to run outside image", + action=action, ) group.add_argument( "--with-network", - action=BooleanAction, + metavar="BOOL", help="Run build and postinst scripts with network access (instead of private network)", + nargs="?", + action=action, ) group.add_argument( "--cache-only", - help="Only use the package cache when installing packages", - action=BooleanAction, metavar="BOOL", + help="Only use the package cache when installing packages", + action=action, ) group.add_argument( "--settings", - dest="nspawn_settings", - help="Add in .nspawn settings file", - type=Path, metavar="PATH", + help="Add in .nspawn settings file", + dest="nspawn_settings", + action=action, ) group.add_argument( '--base-image', + metavar='IMAGE', help='Use the given image as base (e.g. lower sysext layer)', - type=Path, - metavar='IMAGE' + action=action, ) group = parser.add_argument_group("Validation options") group.add_argument( "--checksum", metavar="BOOL", - action=BooleanAction, help="Write SHA256SUMS file", + nargs="?", + action=action, ) group.add_argument( "--sign", - metavar="BOOL", - action=BooleanAction, help="Write and sign SHA256SUMS file", + metavar="BOOL", + nargs="?", + action=action, ) - group.add_argument("--key", help="GPG key to use for signing") + group.add_argument("--key", help="GPG key to use for signing", action=action) group = parser.add_argument_group("Host configuration options") group.add_argument( "--extra-search-path", - dest="extra_search_paths", - action=ColonDelimitedListAction, - default=[], - type=Path, help="List of colon-separated paths to look for programs before looking in PATH", + metavar="PATH", + dest="extra_search_paths", + action=action, ) group.add_argument( "--qemu-gui", - metavar="BOOL", - action=BooleanAction, help="Start QEMU in graphical mode", + metavar="BOOL", + nargs="?", + action=action, ) group.add_argument( "--qemu-smp", metavar="SMP", - default="1", help="Configure guest's SMP settings", + action=action, ) group.add_argument( "--qemu-mem", metavar="MEM", - default="2G", help="Configure guest's RAM size", + action=action, ) group.add_argument( "--qemu-kvm", metavar="BOOL", - action=BooleanAction, help="Configure whether to use KVM or not", - default=qemu_check_kvm_support(), + nargs="?", + action=action, ) group.add_argument( "--qemu-args", - action=RepeatableSpaceDelimitedListAction, - default=[], + metavar="ARGS", # Suppress the command line option because it's already possible to pass qemu args as normal # arguments. help=argparse.SUPPRESS, + action=action, ) group.add_argument( "--ephemeral", metavar="BOOL", - action=BooleanAction, help=('If specified, the container/VM is run with a temporary snapshot of the output ' 'image that is removed immediately when the container/VM terminates'), + nargs="?", + action=action, ) group.add_argument( "--ssh", metavar="BOOL", - action=BooleanAction, help="Set up SSH access from the host to the final image via 'mkosi ssh'", + nargs="?", + action=action, ) group.add_argument( "--credential", - dest="credentials", - action=SpaceDelimitedListAction, - default=[], - help="Pass a systemd credential to systemd-nspawn or qemu", metavar="NAME=VALUE", + help="Pass a systemd credential to systemd-nspawn or qemu", + dest="credentials", + action=action, ) group.add_argument( "--kernel-command-line-extra", metavar="OPTIONS", - action=SpaceDelimitedListAction, - default=[], help="Append extra entries to the kernel command line when booting the image", + action=action, ) group.add_argument( "--acl", metavar="BOOL", - action=BooleanAction, help="Set ACLs on generated directories to permit the user running mkosi to remove them", + nargs="?", + action=action, ) try: @@ -1868,41 +1993,21 @@ def create_parser() -> ArgumentParserMkosi: return parser -def load_distribution(args: argparse.Namespace) -> argparse.Namespace: - if args.distribution is not None: - args.distribution = Distribution[args.distribution] - - if args.distribution is None or args.release is None: - d, r = detect_distribution() - - if args.distribution is None: - args.distribution = d - - if args.distribution == d and args.release is None: - args.release = r - - if args.distribution is None: - die("Couldn't detect distribution.") - - return args - - -def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: - """Load config values from files and parse command line arguments - - Do all about config files and command line arguments parsing. - """ - parser = create_parser() - +def parse_args( + argv: Optional[Sequence[str]] = None, + directory: Optional[Path] = None, + namespace: Optional[argparse.Namespace] = None +) -> argparse.Namespace: if argv is None: argv = sys.argv[1:] argv = list(argv) # make a copy 'cause we'll be modifying the list later on - # If ArgumentParserMkosi loads settings from mkosi configuration files, the settings from files - # are converted to command line arguments. This breaks ArgumentParser's support for default - # values of positional arguments. Make sure the verb command gets explicitly passed. - # Insert a -- before the positional verb argument otherwise it might be considered as an argument of - # a parameter with nargs='?'. For example mkosi -i summary would be treated as -i=summary. + if namespace is None: + namespace = argparse.Namespace() + + # Make sure the verb command gets explicitly passed. Insert a -- before the positional verb argument + # otherwise it might be considered as an argument of a parameter with nargs='?'. For example mkosi -i + # summary would be treated as -i=summary. for verb in Verb: try: v_i = argv.index(verb.name) @@ -1915,72 +2020,26 @@ def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: else: argv += ["--", "build"] - # First run of command line arguments parsing to get the directory of the config file and the verb argument. - args_pre_parsed, _ = parser.parse_known_args(argv) - - if args_pre_parsed.verb == Verb.help: - parser.print_help() - sys.exit(0) - - # Make sure all paths are absolute and valid. - # Relative paths are not valid yet since we are not in the final working directory yet. - if args_pre_parsed.directory is not None: - directory = args_pre_parsed.directory = args_pre_parsed.directory.absolute() - else: - directory = Path.cwd() - - # Note that directory will be ignored if .config_path are absolute - if args_pre_parsed.config_path and not directory.joinpath(args_pre_parsed.config_path).exists(): - die(f"No config file found at {directory / args_pre_parsed.config_path}") - - for name in (args_pre_parsed.config_path, "mkosi.conf"): - if not name: + for s in SETTINGS: + if s.dest in namespace: continue - config_path = directory / name - if config_path.exists(): - break - else: - config_path = directory / "mkosi.default" - - args = parse_args_file_group(argv, config_path) - args = load_distribution(args) - - if args.distribution: - # Parse again with any extra distribution files included. - args = parse_args_file_group(argv, config_path, args.distribution) - - return args - - -def parse_args_file_group( - argv: list[str], config_path: Path, distribution: Optional[Distribution] = None -) -> argparse.Namespace: - """Parse a set of mkosi config files""" - # Add the @ prefixed filenames to current argument list in inverse priority order. - config_files = [] - - if config_path.exists(): - config_files += [f"@{config_path}"] - - d = config_path.parent + if s.default is None: + s.parse(s.dest, None, namespace) + else: + setattr(namespace, s.dest, s.default) - dirs = [Path("mkosi.conf.d"), Path("mkosi.default.d")] - if not d.samefile(Path.cwd()): - dirs += [Path(d / "mkosi.conf.d"), Path(d / "mkosi.default.d")] + if directory: + namespace = MkosiConfigParser(SETTINGS, directory).parse(namespace) - if distribution is not None: - dirs += [d / str(distribution) for d in dirs] + argparser = create_argument_parser() + namespace = argparser.parse_args(argv, namespace) - for dropin_dir in dirs: - if dropin_dir.is_dir(): - for entry in sorted(dropin_dir.iterdir()): - if entry.is_file() and entry.match("*.conf"): - config_files += [f"@{entry}"] + if namespace.verb == Verb.help: + argparser.print_help() + argparser.exit() - # Parse all parameters handled by mkosi. - # Parameters forwarded to subprocesses such as nspawn or qemu end up in cmdline_argv. - return create_parser().parse_args(config_files + argv) + return namespace def empty_directory(path: Path) -> None: @@ -2049,73 +2108,6 @@ def unlink_output(config: MkosiConfig) -> None: empty_directory(config.cache_dir) -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 literal for bool(): {s!r}") - - -def find_extra(args: argparse.Namespace) -> None: - if os.path.isdir("mkosi.extra"): - args.extra_trees.append((Path("mkosi.extra"), None)) - if os.path.isfile("mkosi.extra.tar"): - args.extra_trees.append((Path("mkosi.extra.tar"), None)) - - -def find_skeleton(args: argparse.Namespace) -> None: - if os.path.isdir("mkosi.skeleton"): - args.skeleton_trees.append((Path("mkosi.skeleton"), None)) - if os.path.isfile("mkosi.skeleton.tar"): - args.skeleton_trees.append((Path("mkosi.skeleton.tar"), None)) - - -def args_find_path(args: argparse.Namespace, name: str, path: str, *, as_list: bool = False) -> None: - if getattr(args, name): - return - abspath = Path(path).absolute() - if abspath.exists(): - setattr(args, name, [abspath] if as_list else abspath) - - -def find_output(args: argparse.Namespace) -> None: - subdir = f"{args.distribution}~{args.release}" - - if args.output_dir is not None: - args.output_dir = Path(args.output_dir, subdir) - elif os.path.exists("mkosi.output/"): - args.output_dir = Path("mkosi.output", subdir) - else: - return - - -def find_builddir(args: argparse.Namespace) -> None: - subdir = f"{args.distribution}~{args.release}" - - if args.build_dir is not None: - args.build_dir = Path(args.build_dir, subdir) - elif os.path.exists("mkosi.builddir/"): - args.build_dir = Path("mkosi.builddir", subdir) - else: - return - - -def find_cache(args: argparse.Namespace) -> None: - subdir = f"{args.distribution}~{args.release}" - - if args.cache_dir is not None: - args.cache_dir = Path(args.cache_dir, subdir) - elif os.path.exists("mkosi.cache/"): - args.cache_dir = Path("mkosi.cache", subdir) - else: - return - - def require_private_file(name: Path, description: str) -> None: mode = os.stat(name).st_mode & 0o777 if mode & 0o007: @@ -2125,19 +2117,6 @@ def require_private_file(name: Path, description: str) -> None: """)) -def find_passphrase(args: argparse.Namespace) -> None: - if not needs_build(args): - args.passphrase = None - return - - passphrase = Path("mkosi.passphrase") - if passphrase.exists(): - require_private_file(passphrase, "passphrase") - args.passphrase = passphrase - else: - args.passphrase = None - - def find_password(args: argparse.Namespace) -> None: if not needs_build(args) or args.password is not None: return @@ -2152,19 +2131,6 @@ def find_password(args: argparse.Namespace) -> None: pass -def find_secure_boot(args: argparse.Namespace) -> None: - if not args.secure_boot: - return - - if args.secure_boot_key is None: - if os.path.exists("mkosi.secure-boot.key"): - args.secure_boot_key = Path("mkosi.secure-boot.key") - - if args.secure_boot_certificate is None: - if os.path.exists("mkosi.secure-boot.crt"): - args.secure_boot_certificate = Path("mkosi.secure-boot.crt") - - def find_image_version(args: argparse.Namespace) -> None: if args.image_version is not None: return @@ -2244,19 +2210,6 @@ def load_kernel_command_line_extra(args: argparse.Namespace) -> list[str]: def load_args(args: argparse.Namespace) -> MkosiConfig: ARG_DEBUG.update(args.debug) - args_find_path(args, "nspawn_settings", "mkosi.nspawn") - args_find_path(args, "build_script", "mkosi.build") - args_find_path(args, "install_dir", "mkosi.installdir/") - args_find_path(args, "postinst_script", "mkosi.postinst") - args_find_path(args, "prepare_script", "mkosi.prepare") - args_find_path(args, "finalize_script", "mkosi.finalize") - args_find_path(args, "workspace_dir", "mkosi.workspace/") - args_find_path(args, "repo_dirs", "mkosi.reposdir/", as_list=True) - args_find_path(args, "repart_dir", "mkosi.repart/") - - find_extra(args) - find_skeleton(args) - find_secure_boot(args) find_image_version(args) args.extra_search_paths = expand_paths(args.extra_search_paths) @@ -2264,35 +2217,6 @@ def load_args(args: argparse.Namespace) -> MkosiConfig: if args.cmdline and args.verb not in MKOSI_COMMANDS_CMDLINE: die(f"Parameters after verb are only accepted for {list_to_string(verb.name for verb in MKOSI_COMMANDS_CMDLINE)}.") - if args.output_format is None: - args.output_format = OutputFormat.disk - - args = load_distribution(args) - - if args.release is None: - if args.distribution == Distribution.fedora: - args.release = "36" - elif args.distribution == Distribution.centos: - args.release = "9" - elif args.distribution == Distribution.rocky: - args.release = "9" - elif args.distribution == Distribution.alma: - args.release = "9" - elif args.distribution == Distribution.mageia: - args.release = "7" - elif args.distribution == Distribution.debian: - args.release = "testing" - elif args.distribution == Distribution.ubuntu: - args.release = "jammy" - elif args.distribution == Distribution.opensuse: - args.release = "tumbleweed" - elif args.distribution == Distribution.openmandriva: - args.release = "cooker" - elif args.distribution == Distribution.gentoo: - args.release = "17.1" - else: - args.release = "rolling" - if args.bootable: if args.verb == Verb.qemu and args.output_format in ( OutputFormat.directory, @@ -2305,9 +2229,12 @@ def load_args(args: argparse.Namespace) -> MkosiConfig: if shutil.which("bsdtar") and args.distribution == Distribution.openmandriva and args.tar_strip_selinux_context: die("Sorry, bsdtar on OpenMandriva is incompatible with --tar-strip-selinux-context", MkosiNotSupportedException) - find_cache(args) - find_output(args) - find_builddir(args) + if args.cache_dir: + args.cache_dir = args.cache_dir / f"{args.distribution}~{args.release}" + if args.build_dir: + args.build_dir = args.build_dir / f"{args.distribution}~{args.release}" + if args.output_dir: + args.output_dir = args.output_dir / f"{args.distribution}~{args.release}" if args.mirror is None: if args.distribution in (Distribution.fedora, Distribution.centos): @@ -2348,38 +2275,12 @@ def load_args(args: argparse.Namespace) -> MkosiConfig: output = prefix args.output = Path(output) - if args.manifest_format is None: - args.manifest_format = [ManifestFormat.json] - if args.output_dir is not None: - args.output_dir = args.output_dir.absolute() - if "/" not in str(args.output): args.output = args.output_dir / args.output else: warn("Ignoring configured output directory as output file is a qualified path.") - args.output = args.output.absolute() - - if args.nspawn_settings is not None: - args.nspawn_settings = args.nspawn_settings.absolute() - - if args.build_sources is not None: - args.build_sources = args.build_sources.absolute() - else: - args.build_sources = Path.cwd() - - if args.build_dir is not None: - args.build_dir = args.build_dir.absolute() - - if args.install_dir is not None: - args.install_dir = args.install_dir.absolute() - - args.build_script = normalize_script(args.build_script) - args.prepare_script = normalize_script(args.prepare_script) - args.postinst_script = normalize_script(args.postinst_script) - args.finalize_script = normalize_script(args.finalize_script) - if args.environment: env = {} for s in args.environment: @@ -2393,25 +2294,6 @@ def load_args(args: argparse.Namespace) -> MkosiConfig: args.credentials = load_credentials(args) args.kernel_command_line_extra = load_kernel_command_line_extra(args) - if args.cache_dir is not None: - args.cache_dir = args.cache_dir.absolute() - - if args.extra_trees: - for i in range(len(args.extra_trees)): - source, target = args.extra_trees[i] - args.extra_trees[i] = (source.absolute(), target) - - if args.skeleton_trees is not None: - for i in range(len(args.skeleton_trees)): - source, target = args.skeleton_trees[i] - args.skeleton_trees[i] = (source.absolute(), target) - - if args.secure_boot_key is not None: - args.secure_boot_key = args.secure_boot_key.absolute() - - if args.secure_boot_certificate is not None: - args.secure_boot_certificate = args.secure_boot_certificate.absolute() - if args.secure_boot: if args.secure_boot_key is None: die( @@ -2423,9 +2305,14 @@ def load_args(args: argparse.Namespace) -> MkosiConfig: "UEFI SecureBoot enabled, but couldn't find certificate. (Consider placing it in mkosi.secure-boot.crt?)" ) # NOQA: E501 + if args.sign_expected_pcr is True and not shutil.which("systemd-measure"): + die("Couldn't find systemd-measure binary. It is needed for the --sign-expected-pcr option.") + + if args.sign_expected_pcr is None: + args.sign_expected_pcr = bool(shutil.which("systemd-measure")) + # Resolve passwords late so we can accurately determine whether a build is needed find_password(args) - find_passphrase(args) if args.verb in (Verb.shell, Verb.boot): opname = "acquire shell" if args.verb == Verb.shell else "boot" @@ -2444,17 +2331,17 @@ def load_args(args: argparse.Namespace) -> MkosiConfig: if args.repo_dirs and not (is_dnf_distribution(args.distribution) or args.distribution == Distribution.arch): die("--repo-dir is only supported on DNF based distributions and Arch") - if args.repo_dirs: - args.repo_dirs = [p.absolute() for p in args.repo_dirs] - # If we are building a sysext we don't want to add base packages to the # extension image, as they will already be in the base image. if args.base_image is not None: args.base_packages = False - if args.qemu_kvm and not qemu_check_kvm_support(): + if args.qemu_kvm is True and not qemu_check_kvm_support(): die("Sorry, the host machine does not support KVM acceleration.") + if args.qemu_kvm is None: + args.qemu_kvm = qemu_check_kvm_support() + if args.repositories and not is_dnf_distribution(args.distribution) and args.distribution not in (Distribution.debian, Distribution.ubuntu): die("Sorry, the --repositories option is only supported on DNF/Debian based distributions") @@ -2550,8 +2437,8 @@ def yes_no(b: Optional[bool]) -> str: return "yes" if b else "no" -def yes_no_or(b: Union[bool, str]) -> str: - return b if isinstance(b, str) else yes_no(b) +def yes_no_auto(b: Optional[bool]) -> str: + return "auto" if b is None else yes_no(b) def none_to_na(s: Optional[T]) -> Union[T, str]: @@ -2656,9 +2543,9 @@ def print_summary(config: MkosiConfig) -> None: print(" UEFI SecureBoot:", yes_no(config.secure_boot)) if config.secure_boot_key: - print("SecureBoot Sign Key:", config.secure_boot_key) + print(" SecureBoot Sign Key:", config.secure_boot_key) if config.secure_boot_certificate: - print(" SecureBoot Cert.:", config.secure_boot_certificate) + print(" SecureBoot Certificate:", config.secure_boot_certificate) print("\nCONTENT:") @@ -2675,8 +2562,7 @@ def print_summary(config: MkosiConfig) -> None: print(" Package Cache:", none_to_none(config.cache_dir)) print(" Extra Trees:", line_join_source_target_list(config.extra_trees)) - print(" Skeleton Trees:", line_join_source_target_list(config.skeleton_trees)) - print(" CleanPackageMetadata:", yes_no_or(config.clean_package_metadata)) + print(" CleanPackageMetadata:", yes_no_auto(config.clean_package_metadata)) if config.remove_files: print(" Remove Files:", line_join_list(config.remove_files)) @@ -2932,9 +2818,9 @@ def invoke_repart(state: MkosiState, skip: Sequence[str] = [], split: bool = Fal cmdline += ["--empty=create"] if state.config.passphrase: cmdline += ["--key-file", state.config.passphrase] - if state.config.secure_boot_key.exists(): + if state.config.secure_boot_key: cmdline += ["--private-key", state.config.secure_boot_key] - if state.config.secure_boot_certificate.exists(): + if state.config.secure_boot_certificate: cmdline += ["--certificate", state.config.secure_boot_certificate] if not state.config.bootable: cmdline += ["--exclude-partitions=esp,xbootldr"] @@ -3078,12 +2964,6 @@ def run_build_script(state: MkosiState) -> None: DESTDIR="/work/dest", ) - if state.config.config_path is not None: - env |= dict( - MKOSI_CONFIG=str(state.config.config_path), - MKOSI_DEFAULT=str(state.config.config_path), - ) - if state.config.build_dir is not None: bwrap += ["--bind", state.config.build_dir, "/work/build"] env |= dict(BUILDDIR="/work/build") @@ -3570,7 +3450,7 @@ def generate_secure_boot_key(config: MkosiConfig) -> None: cn = expand_specifier(config.secure_boot_common_name) for f in (config.secure_boot_key, config.secure_boot_certificate): - if f.exists() and not config.force: + if f and not config.force: die( dedent( f"""\ @@ -3591,6 +3471,9 @@ def generate_secure_boot_key(config: MkosiConfig) -> None: ) ) + key = config.secure_boot_key or "mkosi.secure-boot.key" + crt = config.secure_boot_certificate or "mkosi.secure-boot.crt" + cmd: list[PathString] = [ "openssl", "req", @@ -3599,9 +3482,9 @@ def generate_secure_boot_key(config: MkosiConfig) -> None: "-newkey", f"rsa:{keylength}", "-keyout", - config.secure_boot_key, + key, "-out", - config.secure_boot_certificate, + crt, "-days", str(config.secure_boot_valid_days), "-subj", @@ -3689,8 +3572,8 @@ def needs_build(config: Union[argparse.Namespace, MkosiConfig]) -> bool: return config.verb == Verb.build or (config.verb in MKOSI_COMMANDS_NEED_BUILD and (not config.output.exists() or config.force > 0)) -def run_verb(raw: argparse.Namespace) -> None: - config: MkosiConfig = load_args(raw) +def run_verb(args: argparse.Namespace) -> None: + config = load_args(args) with prepend_to_environ_path(config.extra_search_paths): if config.verb == Verb.genkey: diff --git a/mkosi/__main__.py b/mkosi/__main__.py index af8a69381..d6f390b47 100644 --- a/mkosi/__main__.py +++ b/mkosi/__main__.py @@ -5,6 +5,7 @@ import contextlib import os import sys from collections.abc import Iterator +from pathlib import Path from subprocess import CalledProcessError from mkosi import parse_args, run_verb @@ -33,6 +34,7 @@ def main() -> None: else: die(f"Error: {args.directory} is not a directory!") + args = parse_args(directory=Path(".")) run_verb(args) diff --git a/mkosi/backend.py b/mkosi/backend.py index b0a06fcfd..4dba35b2d 100644 --- a/mkosi/backend.py +++ b/mkosi/backend.py @@ -18,7 +18,7 @@ import sys import tarfile from collections.abc import Iterable, Iterator, Sequence from pathlib import Path -from typing import Any, Callable, Optional, TypeVar, Union, cast +from typing import Any, Callable, Optional, TypeVar, Union from mkosi.distributions import DistributionInstaller from mkosi.log import MkosiException, die @@ -36,26 +36,6 @@ def set_umask(mask: int) -> Iterator[int]: os.umask(old) -class Parseable: - "A mix-in to provide conversions for argparse" - - def __str__(self) -> str: - """Return the member name without the class name""" - return cast(str, getattr(self, "name")) - - @classmethod - def from_string(cls: Any, name: str) -> Any: - """A convenience method to be used with argparse""" - try: - return cls[name] - except KeyError: - raise argparse.ArgumentTypeError(f"unknown Format: {name!r}") - - @classmethod - def parse_list(cls: Any, string: str) -> list[Any]: - return [cls.from_string(p) for p in string.split(",") if p] - - class PackageType(enum.Enum): rpm = 1 deb = 2 @@ -182,22 +162,18 @@ def is_dnf_distribution(d: Distribution) -> bool: ) -class OutputFormat(Parseable, enum.Enum): - directory = enum.auto() - subvolume = enum.auto() - tar = enum.auto() - cpio = enum.auto() - disk = enum.auto() +class OutputFormat(str, enum.Enum): + directory = "directory" + subvolume = "subvolume" + tar = "tar" + cpio = "cpio" + disk = "disk" - def __str__(self) -> str: - return Parseable.__str__(self) -class ManifestFormat(Parseable, enum.Enum): +class ManifestFormat(str, enum.Enum): json = "json" # the standard manifest in json format changelog = "changelog" # human-readable text file with package changelogs - def __str__(self) -> str: - return Parseable.__str__(self) KNOWN_SUFFIXES = { ".xz", @@ -243,8 +219,8 @@ class MkosiConfig: bootable: bool kernel_command_line: list[str] secure_boot: bool - secure_boot_key: Path - secure_boot_certificate: Path + secure_boot_key: Optional[Path] + secure_boot_certificate: Optional[Path] secure_boot_valid_days: str secure_boot_common_name: str sign_expected_pcr: bool @@ -260,10 +236,10 @@ class MkosiConfig: remove_packages: list[str] with_docs: bool with_tests: bool - cache_dir: Path + cache_dir: Optional[Path] extra_trees: list[tuple[Path, Optional[Path]]] skeleton_trees: list[tuple[Path, Optional[Path]]] - clean_package_metadata: Union[bool, str] + clean_package_metadata: Optional[bool] remove_files: list[str] environment: dict[str, str] build_sources: Path @@ -290,7 +266,6 @@ class MkosiConfig: ssh: bool credentials: dict[str, str] directory: Optional[Path] - config_path: Optional[Path] debug: list[str] auto_bump: bool workspace_dir: Optional[Path] diff --git a/mkosi/config.py b/mkosi/config.py new file mode 100644 index 000000000..93642e649 --- /dev/null +++ b/mkosi/config.py @@ -0,0 +1,264 @@ +import argparse +import configparser +import dataclasses +import enum +import os +from collections.abc import Sequence +from pathlib import Path +from typing import Any, Callable, Optional, Type, Union, cast + +from mkosi.backend import Distribution +from mkosi.log import die + + +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 + + die(f"Invalid boolean literal: {s!r}") + + +def parse_source_target_paths(value: str) -> tuple[Path, Optional[Path]]: + src, _, target = value.partition(':') + if target and not Path(target).absolute(): + die("Target path must be absolute") + return Path(src), Path(target) if target else None + + +def config_parse_string(dest: str, value: Optional[str], namespace: argparse.Namespace) -> None: + setattr(namespace, dest, value) + + +def config_match_string(dest: str, value: str, namespace: argparse.Namespace) -> bool: + return cast(bool, value == getattr(namespace, dest)) + + +def config_parse_script(dest: str, value: Optional[str], namespace: argparse.Namespace) -> None: + if value is not None: + if not Path(value).exists(): + die(f"{value} does not exist") + if not os.access(value, os.X_OK): + die(f"{value} is not executable") + + config_make_path_parser(required=True)(dest, value, namespace) + + +def config_parse_boolean(dest: str, value: Optional[str], namespace: argparse.Namespace) -> None: + setattr(namespace, dest, parse_boolean(value) if value is not None else False) + + +def config_match_boolean(dest: str, value: str, namespace: argparse.Namespace) -> bool: + return cast(bool, getattr(namespace, dest) == parse_boolean(value)) + + +def config_parse_feature(dest: str, value: Optional[str], namespace: argparse.Namespace) -> None: + if value is None: + value = "auto" + setattr(namespace, dest, parse_boolean(value) if value != "auto" else None) + + +def config_parse_compression(dest: str, value: Optional[str], namespace: argparse.Namespace) -> None: + if value in ("zlib", "lzo", "zstd", "lz4", "xz"): + setattr(namespace, dest, value) + else: + setattr(namespace, dest, parse_boolean(value) if value is not None else None) + + +def config_parse_base_packages(dest: str, value: Optional[str], namespace: argparse.Namespace) -> None: + if value == "conditional": + setattr(namespace, dest, value) + else: + setattr(namespace, dest, parse_boolean(value) if value is not None else False) + + +def config_parse_distribution(dest: str, value: Optional[str], namespace: argparse.Namespace) -> None: + assert value is not None + + try: + d = Distribution[value] + except KeyError: + die(f"Invalid distribution {value}") + + r = { + Distribution.fedora: "37", + Distribution.centos: "9", + Distribution.rocky: "9", + Distribution.alma: "9", + Distribution.mageia: "7", + Distribution.debian: "testing", + Distribution.ubuntu: "jammy", + Distribution.opensuse: "tumbleweed", + Distribution.openmandriva: "cooker", + Distribution.gentoo: "17.1", + }.get(d, "rolling") + + setattr(namespace, dest, d) + setattr(namespace, "release", r) + + +ConfigParseCallback = Callable[[str, Optional[str], argparse.Namespace], None] +ConfigMatchCallback = Callable[[str, str, argparse.Namespace], bool] + + +@dataclasses.dataclass(frozen=True) +class MkosiConfigSetting: + dest: str + section: str + parse: ConfigParseCallback = config_parse_string + match: Optional[ConfigMatchCallback] = None + name: str = "" + default: Any = None + paths: tuple[str, ...] = tuple() + + def __post_init__(self) -> None: + if not self.name: + object.__setattr__(self, 'name', ''.join(x.capitalize() for x in self.dest.split('_') if x)) + + +class MkosiConfigParser: + def __init__(self, settings: Sequence[MkosiConfigSetting], directory: Path) -> None: + self.settings = settings + self.directory = directory + self.lookup = {s.name: s for s in settings} + + def _parse_config(self, path: Path, namespace: argparse.Namespace) -> None: + extras = path.is_dir() + + if path.is_dir(): + path = path / "mkosi.conf" + + parser = configparser.ConfigParser( + delimiters="=", + comment_prefixes="#", + inline_comment_prefixes="#", + empty_lines_in_values=True, + interpolation=None, + ) + + parser.optionxform = lambda optionstr: optionstr # type: ignore + + if path.exists(): + parser.read(path) + + if "Match" in parser.sections(): + for k, v in parser.items("Match"): + if not (s := self.lookup.get(k)): + die(f"Unknown setting {k}") + + if s.match and not s.match(s.dest, v, namespace): + return + + parser.remove_section("Match") + + if extras: + for s in self.settings: + for f in s.paths: + if path.parent.joinpath(f).exists(): + s.parse(s.dest, str(path.parent / f), namespace) + + for section in parser.sections(): + for k, v in parser.items(section): + if not (s := self.lookup.get(k)): + die(f"Unknown setting {k}") + + s.parse(s.dest, v, namespace) + + if extras and path.parent.joinpath("mkosi.conf.d").exists(): + for p in sorted(path.parent.joinpath("mkosi.conf.d").iterdir()): + if p.is_dir() or p.suffix == ".conf": + self._parse_config(p, namespace) + + + def parse(self, namespace: argparse.Namespace = argparse.Namespace()) -> argparse.Namespace: + self._parse_config(self.directory, namespace) + return namespace + + +def config_make_action(settings: Sequence[MkosiConfigSetting]) -> Type[argparse.Action]: + lookup = {s.dest: s for s in settings} + + class MkosiAction(argparse.Action): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: Union[str, Sequence[Any], None], + option_string: Optional[str] = None + ) -> None: + assert option_string is not None + + if values is None and self.nargs == "?": + values = self.const or "yes" + + try: + s = lookup[self.dest] + except KeyError: + die(f"Unknown setting {option_string}") + + if values is None or isinstance(values, str): + s.parse(self.dest, values, namespace) + else: + for v in values: + assert isinstance(v, str) + s.parse(self.dest, v, namespace) + + return MkosiAction + + +def make_enum_parser(type: Type[enum.Enum]) -> Callable[[str], enum.Enum]: + def parse_enum(value: str) -> enum.Enum: + try: + return type[value] + except KeyError: + die(f"Invalid enum value {value}") + + return parse_enum + + +def config_make_enum_parser(type: Type[enum.Enum]) -> ConfigParseCallback: + def config_parse_enum(dest: str, value: Optional[str], namespace: argparse.Namespace) -> None: + setattr(namespace, dest, make_enum_parser(type)(value) if value else None) + + return config_parse_enum + + +def config_make_enum_matcher(type: Type[enum.Enum]) -> ConfigMatchCallback: + def config_match_enum(dest: str, value: str, namespace: argparse.Namespace) -> bool: + return cast(bool, make_enum_parser(type)(value) == getattr(namespace, dest)) + + return config_match_enum + + +def config_make_list_parser(delimiter: str, parse: Callable[[str], Any] = str) -> ConfigParseCallback: + def config_parse_list(dest: str, value: Optional[str], namespace: argparse.Namespace) -> None: + if not value: + setattr(namespace, dest, []) + return + + value = value.replace("\n", delimiter) + values = [v for v in value.split(delimiter) if v] + + for v in values: + if v == "!*": + getattr(namespace, dest).clear() + elif v.startswith("!"): + setattr(namespace, dest, [i for i in getattr(namespace, dest) if i == parse(v[1:])]) + else: + getattr(namespace, dest).append(parse(v)) + + return config_parse_list + + +def config_make_path_parser(required: bool) -> ConfigParseCallback: + def config_parse_path(dest: str, value: Optional[str], namespace: argparse.Namespace) -> None: + if value is not None and required and not Path(value).exists(): + die(f"{value} does not exist") + + setattr(namespace, dest, Path(value) if value else None) + + return config_parse_path diff --git a/mkosi/distributions/debian.py b/mkosi/distributions/debian.py index a9eafd329..6bbbe20b1 100644 --- a/mkosi/distributions/debian.py +++ b/mkosi/distributions/debian.py @@ -65,7 +65,7 @@ class DebianInstaller(DistributionInstaller): "--variant=minbase", "--include=ca-certificates", "--merged-usr", - f"--cache-dir={state.cache}", + f"--cache-dir={state.cache.absolute()}", f"--components={','.join(repos)}", ] @@ -282,15 +282,15 @@ def invoke_apt( f"""\ APT::Architecture "{debarch}"; APT::Immediate-Configure "off"; - Dir::Cache "{state.cache}"; - Dir::State "{state.workspace / "apt"}"; + Dir::Cache "{state.cache.absolute()}"; + Dir::State "{state.workspace.absolute() / "apt"}"; Dir::State::status "{state.root / "var/lib/dpkg/status"}"; - Dir::Etc "{state.root / "etc/apt"}"; - Dir::Log "{state.workspace / "apt/log"}"; + Dir::Etc "{state.root.absolute() / "etc/apt"}"; + Dir::Log "{state.workspace.absolute() / "apt/log"}"; Dir::Bin::dpkg "dpkg"; DPkg::Path "{os.environ["PATH"]}"; - DPkg::Options:: "--root={state.root}"; - DPkg::Options:: "--log={state.workspace / "apt/dpkg.log"}"; + DPkg::Options:: "--root={state.root.absolute()}"; + DPkg::Options:: "--log={state.workspace.absolute() / "apt/dpkg.log"}"; DPkg::Install::Recursive::Minimum "1000"; """ ) diff --git a/mkosi/log.py b/mkosi/log.py index 5e666f830..9101976e6 100644 --- a/mkosi/log.py +++ b/mkosi/log.py @@ -15,7 +15,7 @@ class MkosiNotSupportedException(MkosiException): def die(message: str, exception: type[MkosiException] = MkosiException) -> NoReturn: - MkosiPrinter.warn(f"Error: {message}") + MkosiPrinter.error(f"Error: {message}") raise exception(message) @@ -68,6 +68,10 @@ class MkosiPrinter: def warn(cls, text: str) -> None: cls._print(f"{cls.prefix}{cls.color_warning(text)}\n") + @classmethod + def error(cls, text: str) -> None: + cls._print(f"{cls.prefix}{cls.color_error(text)}\n") + @classmethod @contextlib.contextmanager def complete_step(cls, text: str, text2: Optional[str] = None) -> Iterator[list[Any]]: diff --git a/tests/test_parse_load_args.py b/tests/test_parse_load_args.py index 137f45adc..0a53f3be9 100644 --- a/tests/test_parse_load_args.py +++ b/tests/test_parse_load_args.py @@ -14,10 +14,6 @@ from mkosi.backend import Distribution, MkosiConfig, Verb from mkosi.log import MkosiException -def parse(argv: Optional[List[str]] = None) -> MkosiConfig: - return mkosi.load_args(mkosi.parse_args(argv)) - - @contextmanager def cd_temp_dir() -> Iterator[None]: old_dir = getcwd() @@ -29,36 +25,44 @@ def cd_temp_dir() -> Iterator[None]: finally: chdir(old_dir) + +def parse(argv: Optional[List[str]] = None) -> MkosiConfig: + return mkosi.load_args(mkosi.parse_args(argv, directory=Path("."))) + + def test_parse_load_verb() -> None: - assert parse(["build"]).verb == Verb.build - assert parse(["clean"]).verb == Verb.clean - with pytest.raises(SystemExit): - parse(["help"]) - assert parse(["genkey"]).verb == Verb.genkey - assert parse(["bump"]).verb == Verb.bump - assert parse(["serve"]).verb == Verb.serve - assert parse(["build"]).verb == Verb.build - assert parse(["shell"]).verb == Verb.shell - assert parse(["boot"]).verb == Verb.boot - assert parse(["--bootable", "qemu"]).verb == Verb.qemu - with pytest.raises(SystemExit): - parse(["invalid"]) + with cd_temp_dir(): + assert parse(["build"]).verb == Verb.build + assert parse(["clean"]).verb == Verb.clean + with pytest.raises(SystemExit): + parse(["help"]) + assert parse(["genkey"]).verb == Verb.genkey + assert parse(["bump"]).verb == Verb.bump + assert parse(["serve"]).verb == Verb.serve + assert parse(["build"]).verb == Verb.build + assert parse(["shell"]).verb == Verb.shell + assert parse(["boot"]).verb == Verb.boot + assert parse(["--bootable", "qemu"]).verb == Verb.qemu + with pytest.raises(SystemExit): + parse(["invalid"]) + def test_os_distribution() -> None: - for dist in Distribution: - assert parse(["-d", dist.name]).distribution == dist + with cd_temp_dir(): + for dist in Distribution: + assert parse(["-d", dist.name]).distribution == dist - with pytest.raises(tuple((argparse.ArgumentError, SystemExit))): - parse(["-d", "invalidDistro"]) - with pytest.raises(tuple((argparse.ArgumentError, SystemExit))): - parse(["-d"]) + with pytest.raises(tuple((argparse.ArgumentError, SystemExit))): + parse(["-d", "invalidDistro"]) + with pytest.raises(tuple((argparse.ArgumentError, SystemExit))): + parse(["-d"]) - for dist in Distribution: - with cd_temp_dir(): + for dist in Distribution: config = Path("mkosi.conf") config.write_text(f"[Distribution]\nDistribution={dist}") assert parse([]).distribution == dist + def test_parse_config_files_filter() -> None: with cd_temp_dir(): confd = Path("mkosi.conf.d") @@ -69,28 +73,33 @@ def test_parse_config_files_filter() -> None: assert parse([]).packages == ["yes"] -def test_hostname() -> None: - assert parse(["--hostname", "name"]).hostname == "name" - with pytest.raises(SystemExit): - parse(["--hostname", "name", "additional_name"]) - with pytest.raises(SystemExit): - parse(["--hostname"]) +def test_hostname() -> None: with cd_temp_dir(): + assert parse(["--hostname", "name"]).hostname == "name" + with pytest.raises(SystemExit): + parse(["--hostname", "name", "additional_name"]) + with pytest.raises(SystemExit): + parse(["--hostname"]) + config = Path("mkosi.conf") config.write_text("[Output]\nHostname=name") assert parse([]).hostname == "name" + def test_shell_boot() -> None: - with pytest.raises(MkosiException, match=".boot.*tar"): - parse(["--format", "tar", "boot"]) + with cd_temp_dir(): + with pytest.raises(MkosiException, match=".boot.*tar"): + parse(["--format", "tar", "boot"]) - with pytest.raises(MkosiException, match=".boot.*cpio"): - parse(["--format", "cpio", "boot"]) + with pytest.raises(MkosiException, match=".boot.*cpio"): + parse(["--format", "cpio", "boot"]) + + with pytest.raises(MkosiException, match=".boot.*compressed" ): + parse(["--format", "disk", "--compress-output=yes", "boot"]) - with pytest.raises(MkosiException, match=".boot.*compressed" ): - parse(["--format", "disk", "--compress-output=yes", "boot"]) def test_compression() -> None: - assert not parse(["--format", "disk", "--compress-output", "False"]).compress_output + with cd_temp_dir(): + assert not parse(["--format", "disk", "--compress-output", "False"]).compress_output