]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
Add Include= setting 1941/head
authorDaan De Meyer <daan.j.demeyer@gmail.com>
Sat, 30 Sep 2023 14:43:05 +0000 (16:43 +0200)
committerDaan De Meyer <daan.j.demeyer@gmail.com>
Mon, 2 Oct 2023 07:25:07 +0000 (09:25 +0200)
This setting allows including extra configuration from user specified
directories or files. The extra configuration is parsed immediately.

mkosi/config.py
mkosi/resources/mkosi.md
tests/test_config.py

index 915c4688f4ed615846eb91d7df4a5f7609081059..ddf4d2b5f9ff90cb3748a741158a5266902e80ff 100644 (file)
@@ -2,6 +2,7 @@
 
 import argparse
 import base64
+import contextlib
 import copy
 import dataclasses
 import enum
@@ -20,7 +21,7 @@ import textwrap
 import uuid
 from collections.abc import Collection, Iterable, Iterator, Sequence
 from pathlib import Path
-from typing import Any, Callable, Collection, Optional, Union, cast
+from typing import Any, Callable, Optional, Type, Union, cast
 
 from mkosi.architecture import Architecture
 from mkosi.distributions import Distribution, detect_distribution
@@ -365,7 +366,8 @@ def config_make_enum_matcher(type: type[enum.Enum]) -> ConfigMatchCallback:
 def config_make_list_parser(delimiter: str,
                             *,
                             parse: Callable[[str], Any] = str,
-                            unescape: bool = False) -> ConfigParseCallback:
+                            unescape: bool = False,
+                            reset: bool = True) -> ConfigParseCallback:
     def config_parse_list(value: Optional[str], old: Optional[list[Any]]) -> Optional[list[Any]]:
         new = old.copy() if old else []
 
@@ -382,7 +384,7 @@ def config_make_list_parser(delimiter: str,
             values = value.replace(delimiter, "\n").split("\n")
 
         # Empty strings reset the list.
-        if len(values) == 1 and values[0] == "":
+        if reset and len(values) == 1 and values[0] == "":
             return None
 
         return new + [parse(v) for v in values if v]
@@ -598,37 +600,6 @@ class IgnoreAction(argparse.Action):
         logging.warning(f"{option_string} is no longer supported")
 
 
-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):
-                setattr(namespace, s.dest, s.parse(values, getattr(namespace, self.dest, None)))
-            else:
-                for v in values:
-                    assert isinstance(v, str)
-                    setattr(namespace, s.dest, s.parse(v, getattr(namespace, self.dest, None)))
-
-    return MkosiAction
-
-
 class PagerHelpAction(argparse._HelpAction):
     def __call__(
         self,
@@ -672,6 +643,7 @@ class MkosiConfig:
     access the value from state.
     """
 
+    include: tuple[str, ...]
     presets: tuple[str]
     dependencies: tuple[str]
 
@@ -934,6 +906,12 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple
 
 
 SETTINGS = (
+    MkosiConfigSetting(
+        dest="include",
+        section="Config",
+        parse=config_make_list_parser(delimiter=",", reset=False, parse=make_path_parser()),
+        help="Include configuration from the specified file or directory",
+    ),
     MkosiConfigSetting(
         dest="presets",
         long="--preset",
@@ -1767,9 +1745,7 @@ MATCHES = (
 )
 
 
-def create_argument_parser() -> argparse.ArgumentParser:
-    action = config_make_action(SETTINGS)
-
+def create_argument_parser(action: Type[argparse.Action]) -> argparse.ArgumentParser:
     parser = argparse.ArgumentParser(
         prog="mkosi",
         description="Build Bespoke OS Images",
@@ -1958,6 +1934,46 @@ def parse_config(argv: Sequence[str] = ()) -> tuple[MkosiArgs, tuple[MkosiConfig
     settings_lookup_by_dest = {s.dest: s for s in SETTINGS}
     match_lookup = {m.name: m for m in MATCHES}
 
+    @contextlib.contextmanager
+    def parse_new_includes(
+        namespace: argparse.Namespace,
+        defaults: argparse.Namespace,
+    ) -> Iterator[None]:
+        l = len(getattr(namespace, "include", []))
+
+        try:
+            yield
+        finally:
+            # Parse any includes that were added after yielding.
+            for p in getattr(namespace, "include", [])[l:]:
+                parse_config(p, namespace, defaults)
+
+    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 = settings_lookup_by_dest[self.dest]
+            except KeyError:
+                die(f"Unknown setting {option_string}")
+
+            with parse_new_includes(namespace, defaults):
+                if values is None or isinstance(values, str):
+                    setattr(namespace, s.dest, s.parse(values, getattr(namespace, self.dest, None)))
+                else:
+                    for v in values:
+                        assert isinstance(v, str)
+                        setattr(namespace, s.dest, s.parse(v, getattr(namespace, self.dest, None)))
+
     def finalize_default(
         setting: MkosiConfigSetting,
         namespace: argparse.Namespace,
@@ -1978,7 +1994,9 @@ def parse_config(argv: Sequence[str] = ()) -> tuple[MkosiArgs, tuple[MkosiConfig
         else:
             default = setting.default
 
-        setattr(namespace, setting.dest, default)
+        with parse_new_includes(namespace, defaults):
+            setattr(namespace, setting.dest, default)
+
         return default
 
     def match_config(path: Path, namespace: argparse.Namespace, defaults: argparse.Namespace) -> bool:
@@ -2053,7 +2071,8 @@ def parse_config(argv: Sequence[str] = ()) -> tuple[MkosiArgs, tuple[MkosiConfig
                     canonical = s.name if k == name else f"@{s.name}"
                     logging.warning(f"Setting {k} is deprecated, please use {canonical} instead.")
 
-                setattr(ns, s.dest, s.parse(v, getattr(ns, s.dest, None)))
+                with parse_new_includes(namespace, defaults):
+                    setattr(ns, s.dest, s.parse(v, getattr(ns, s.dest, None)))
 
         if extras:
             for s in SETTINGS:
@@ -2105,7 +2124,7 @@ def parse_config(argv: Sequence[str] = ()) -> tuple[MkosiArgs, tuple[MkosiConfig
         argv += ["--", "build"]
 
     namespace = argparse.Namespace()
-    argparser = create_argument_parser()
+    argparser = create_argument_parser(MkosiAction)
     argparser.parse_args(argv, namespace)
 
     args = load_args(namespace)
@@ -2396,6 +2415,9 @@ def summary(args: MkosiArgs, config: MkosiConfig) -> str:
                           Verb: {bold(args.verb)}
                        Cmdline: {bold(" ".join(args.cmdline))}
 
+    {bold("CONFIG")}:
+                       Include: {line_join_list(config.include)}
+
     {bold("PRESET")}:
                        Presets: {line_join_list(config.presets)}
                   Dependencies: {line_join_list(config.dependencies)}
index 95231d83eb0cc3982c0c560a96b2ca3157e15cd7..c7ce314515bc03be80a63df4d06e68290cd8e3c0 100644 (file)
@@ -356,6 +356,16 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`,
 | `Format=`         | no    | no               | match default format    |
 | `SystemdVersion=` | no    | yes              | match fails             |
 
+### [Config] Section
+
+`Include=`, `--include=`
+
+: Include extra configuration from the given file or directory. The
+  extra configuration is included immediately after parsing the setting,
+  except when a default is set using `@Include=`, in which case the
+  configuration is included after parsing all the other configuration
+  files.
+
 ### [Preset] Section
 
 `Presets=`, `--preset=`
index d2ce5de6f6ab5525f61603a47f2effe5d93f0fe7..f2e1519cbeeaf7389ca686c480a1ebc17f4ecbbc 100644 (file)
@@ -4,13 +4,21 @@ import argparse
 import itertools
 import logging
 import operator
+import os
 from pathlib import Path
 from typing import Optional
 
 import pytest
 
 from mkosi.architecture import Architecture
-from mkosi.config import Compression, OutputFormat, Verb, parse_config, parse_ini
+from mkosi.config import (
+    Compression,
+    ConfigFeature,
+    OutputFormat,
+    Verb,
+    parse_config,
+    parse_ini,
+)
 from mkosi.distributions import Distribution
 from mkosi.util import chdir
 
@@ -172,6 +180,36 @@ def test_parse_config(tmp_path: Path) -> None:
     # ImageVersion= is not set explicitly anymore, so now the version from mkosi.version should be used.
     assert config.image_version == "1.2.3"
 
+    (tmp_path / "abc").mkdir()
+    (tmp_path / "abc/mkosi.conf").write_text(
+        """\
+        [Content]
+        Bootable=yes
+        """
+    )
+    (tmp_path / "abc/mkosi.conf.d").mkdir()
+    (tmp_path / "abc/mkosi.conf.d/abc.conf").write_text(
+        """\
+        [Output]
+        SplitArtifacts=yes
+        """
+    )
+
+    with chdir(tmp_path):
+        _, [config] = parse_config()
+        assert config.bootable == ConfigFeature.auto
+        assert config.split_artifacts == False
+
+        # Passing the directory should include both the main config file and the dropin.
+        _, [config] = parse_config(["--include", os.fspath(tmp_path / "abc")])
+        assert config.bootable == ConfigFeature.enabled
+        assert config.split_artifacts == True
+
+        # Passing the main config file should not include the dropin.
+        _, [config] = parse_config(["--include", os.fspath(tmp_path / "abc/mkosi.conf")])
+        assert config.bootable == ConfigFeature.enabled
+        assert config.split_artifacts == False
+
 
 def test_parse_load_verb(tmp_path: Path) -> None:
     (tmp_path / "mkosi.conf").write_text("[Distribution]\nDistribution=fedora")
@@ -558,7 +596,7 @@ def test_wrong_section_warning(
                 f"""\
                 [Distribution]
                 Distribution=fedora
-                
+
                 [{section}]
                 ImageId=testimage
                 """