Repository: systemd/mkosi

Issue:
`ExtraTrees=` via `InitrdInclude=` incorrectly resolved
When using `ExtraTrees=` fom a *file* which is included via `InitrdInclude=` which itself was set in the config file of an image the paths are incorrectly resolved. More specifically they seem to be resolved relative to the project root instead of the config file (or the image root).

I would have expected that paths are resolved relative to the included config file. This is also the case when including a directory which contains a `mkosi.conf` file.

I have not yet had the time to check if this also happens with a normal include, without the image step, or with attributes which resolve paths.


Discussion:
This is expected behavior, we only change directory when parsing a directory containing a mkosi.conf file. If we're parsing a regular file, we do not change directory.

Resolving patch:
diff --git a/mkosi/config.py b/mkosi/config.py
--- a/mkosi/config.py
+++ b/mkosi/config.py
@@ -1133,6 +1133,12 @@ class Match:
     match: Callable[[str], bool]
 
 
+@dataclasses.dataclass(frozen=True)
+class Specifier:
+    char: str
+    callback: Callable[[argparse.Namespace, Path], str]
+
+
 class CustomHelpFormatter(argparse.HelpFormatter):
     def _format_action_invocation(self, action: argparse.Action) -> str:
         if not action.option_strings or action.nargs == 0:
@@ -2916,6 +2922,23 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple
 
 MATCH_LOOKUP = {m.name: m for m in MATCHES}
 
+SPECIFIERS = (
+    Specifier(
+        char="C",
+        callback=lambda ns, config: os.fspath(config.resolve().parent),
+    ),
+    Specifier(
+        char="P",
+        callback=lambda ns, config: os.fspath(Path.cwd()),
+    ),
+    Specifier(
+        char="D",
+        callback=lambda ns, config: os.fspath(ns.directory.resolve()),
+    ),
+)
+
+SPECIFIERS_LOOKUP_BY_CHAR = {s.char: s for s in SPECIFIERS}
+
 # This regular expression can be used to split "AutoBump" -> ["Auto", "Bump"]
 # and "NSpawnSettings" -> ["NSpawn", "Settings"]
 # The first part (?<=[a-z]) is a positive look behind for a lower case letter
@@ -3128,7 +3151,7 @@ def parse_config(argv: Sequence[str] = (), *, resources: Path = Path("/")) -> tu
     parsed_includes: set[tuple[int, int]] = set()
     immutable_settings: set[str] = set()
 
-    def expand_specifiers(text: str) -> str:
+    def expand_specifiers(text: str, path: Path) -> str:
         percent = False
         result: list[str] = []
 
@@ -3138,19 +3161,18 @@ def expand_specifiers(text: str) -> str:
 
                 if c == "%":
                     result += "%"
-                else:
-                    s = SETTINGS_LOOKUP_BY_SPECIFIER.get(c)
-                    if not s:
-                        logging.warning(f"Unknown specifier '%{c}' found in {text}, ignoring")
-                        continue
-
-                    if (v := finalize_default(s)) is None:
+                elif setting := SETTINGS_LOOKUP_BY_SPECIFIER.get(c):
+                    if (v := finalize_default(setting)) is None:
                         logging.warning(
-                            f"Setting {s.name} specified by specifier '%{c}' in {text} is not yet set, ignoring"
+                            f"Setting {setting.name} specified by specifier '%{c}' in {text} is not yet set, ignoring"
                         )
                         continue
 
                     result += str(v)
+                elif specifier := SPECIFIERS_LOOKUP_BY_CHAR.get(c):
+                    result += specifier.callback(namespace, path)
+                else:
+                    logging.warning(f"Unknown specifier '%{c}' found in {text}, ignoring")
             elif c == "%":
                 percent = True
             else:
@@ -3273,7 +3295,7 @@ def match_config(path: Path) -> bool:
             negate = v.startswith("!")
             v = v.removeprefix("!")
 
-            v = expand_specifiers(v)
+            v = expand_specifiers(v, path)
 
             if not v:
                 die("Match value cannot be empty")
@@ -3365,7 +3387,7 @@ def parse_config_one(path: Path, profiles: bool = False) -> bool:
                     canonical = s.name if k == name else f"@{s.name}"
                     logging.warning(f"Setting {k} is deprecated, please use {canonical} instead.")
 
-                v = expand_specifiers(v)
+                v = expand_specifiers(v, path)
 
                 with parse_new_includes():
                     setattr(ns, s.dest, s.parse(v, getattr(ns, s.dest, None)))


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -920,6 +920,28 @@ def test_specifiers(tmp_path: Path) -> None:
                     ImageVersion=%v
                     OutputDirectory=%O
                     Output=%o
+                    ConfigRootDirectory=%D
+                    ConfigRootConfdir=%C
+                    ConfigRootPwd=%P
+        """
+    )
+
+    (d / "mkosi.conf.d").mkdir()
+    (d / "mkosi.conf.d/abc.conf").write_text(
+        """\
+        [Content]
+        Environment=ConfigAbcDirectory=%D
+                    ConfigAbcConfdir=%C
+                    ConfigAbcPwd=%P
+        """
+    )
+    (d / "mkosi.conf.d/qed").mkdir()
+    (d / "mkosi.conf.d/qed/mkosi.conf").write_text(
+        """
+        [Content]
+        Environment=ConfigQedDirectory=%D
+                    ConfigQedConfdir=%C
+                    ConfigQedPwd=%P
         """
     )
 
@@ -934,6 +956,15 @@ def test_specifiers(tmp_path: Path) -> None:
             "ImageVersion": "1.2.3",
             "OutputDirectory": str(Path.cwd() / "abcde"),
             "Output": "test",
+            "ConfigRootDirectory": os.fspath(d),
+            "ConfigRootConfdir": os.fspath(d),
+            "ConfigRootPwd": os.fspath(d),
+            "ConfigAbcDirectory": os.fspath(d),
+            "ConfigAbcConfdir": os.fspath(d / "mkosi.conf.d"),
+            "ConfigAbcPwd": os.fspath(d),
+            "ConfigQedDirectory": os.fspath(d),
+            "ConfigQedConfdir": os.fspath(d / "mkosi.conf.d/qed"),
+            "ConfigQedPwd": os.fspath(d / "mkosi.conf.d/qed"),
         }
 
         assert {k: v for k, v in config.environment.items() if k in expected} == expected