]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
Rework configuration parsing 1420/head
authorDaan De Meyer <daan.j.demeyer@gmail.com>
Mon, 3 Apr 2023 09:51:33 +0000 (11:51 +0200)
committerDaan De Meyer <daan.j.demeyer@gmail.com>
Thu, 6 Apr 2023 18:36:38 +0000 (20:36 +0200)
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.

.github/workflows/ci.yml
mkosi.md
mkosi/__init__.py
mkosi/__main__.py
mkosi/backend.py
mkosi/config.py [new file with mode: 0644]
mkosi/distributions/debian.py
mkosi/log.py
tests/test_parse_load_args.py

index d0c24314abb60524ccdf649173fe33c14e3511a4..69c40ba639d5008b768b18c5aa7749a8089360fd 100644 (file)
@@ -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
index f499ac8c031076c4451b0ad8d112fa45eb26387b..9d830e123bffb69d3adffbba483a09cd5c5fbc21 100644 (file)
--- 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`
 
index f8b717a938235b728c69f56ad6e47172ea3dd79a..736b3e47f73f08a0e279bda38adf046be093b002 100644 (file)
@@ -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:
index af8a69381e1c87399ca09bfbbdbaa07d0922f5a7..d6f390b4758ac3d3a3262bd73adddc202840830a 100644 (file)
@@ -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)
 
 
index b0a06fcfd57e384c85a6c5debc67e9ae502acf6b..4dba35b2da109dee1cf61886b3ad9ae2a6349e22 100644 (file)
@@ -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 (file)
index 0000000..93642e6
--- /dev/null
@@ -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
index a9eafd3297bbb748d1d4ec4c2881ba491ea87b7d..6bbbe20b1b15bf0ea7730c0192bc7bc00af398c8 100644 (file)
@@ -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";
             """
         )
index 5e666f83006653a671f750f5c174a6208d71dad3..9101976e6627448d5e21dc4bc1de322d905c32d6 100644 (file)
@@ -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]]:
index 137f45adce0523b11b251abe77e04c0975590804..0a53f3be96dcb21d2a0fd0e594022b3303d9f507 100644 (file)
@@ -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