From fd3d9bfe57f784bdd69715cd3b1e4e45d65a9ef3 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Fri, 26 Jun 2026 10:21:33 +0200 Subject: [PATCH 1/3] tests: Add regression test for build history lookup via configured output dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A build can be told its output directory on the command line (`-O`) while a later verb that consumes the build (vm, boot, summary, …) only knows it from `OutputDirectory=` in the configuration. As long as both point at the same directory, the consumer must still find the build history the build wrote. This is what systemd does: its meson build passes `--output-dir` explicitly, while a developer running `mkosi vm` (without `--output-dir`) afterwards relies on `OutputDirectory=`. Capture this behaviour so we don't regress it again. This broke once in commit da49fe976c ("Put build history into the output directory"), which keyed the history location on the CLI value only, and was reverted in commit 582eadee343. --- tests/test_config.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index a3fef072c9..a7c9124089 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1992,3 +1992,35 @@ def test_history_empty_list(tmp_path: Path) -> None: _, _, [main] = parse_config(["summary"]) assert main.package_directories == [] + + +def test_history_found_via_configured_output_directory(tmp_path: Path) -> None: + d = tmp_path + + out = d / "out" + + # Mirror what systemd does: it both declares OutputDirectory= in config *and* passes the same path via + # -O on the build command line (from its meson build), while consumers rely on the configured value + # alone. + (d / "mkosi.conf").write_text( + f"""\ + [Distribution] + Distribution=fedora + + [Output] + OutputDirectory={out} + + [Build] + History=yes + """ + ) + + with chdir(d): + # The build passes -O explicitly, pointing at the same directory the config already configures. + parse_config(["--output-directory", os.fspath(out), "--image-id", "img-x", "build"]) + + # A consumer relies on the configured OutputDirectory and passes no -O. It must still read back + # the build's history. + _, _, [config] = parse_config(["summary"]) + + assert config.image_id == "img-x" From 9bc430a7fa71101ce75d381f502e744949c3f60d Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Fri, 26 Jun 2026 10:36:25 +0200 Subject: [PATCH 2/3] config: Isolate build history per output directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When building into separate output directories (e.g. the integration test suite running in parallel), all builds shared a single build history in the config directory. A verb that consumes a previous build (vm, boot, summary, …) then read back whichever build ran last instead of the one for the output directory it was pointed at. To fix that and keep an output directory isolated, store the history in it as well, and prefer it when given `--output-directory`. The config directory copy is kept and is still used when no `-O` is passed, so a consumer that gets the output directory from `OutputDirectory=` in the configuration keeps working (and the output-dir lookup falls back to it, status quo ante). This is what commit da49fe976c73 tried to do, but it stored the history *only* in the output directory keyed on the CLI value, which broke consumers that take the output directory from the configuration. Keying the read on the CLI value and keeping the config-dir copy avoids that regression. Cover this in the new `test_history_found_via_configured_output_directory()`. --- mkosi/config.py | 26 ++++++++++++++++++++++---- tests/test_config.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/mkosi/config.py b/mkosi/config.py index 1008495071..cfaa73970d 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -5445,7 +5445,12 @@ def want_default_initrd(config: Config) -> bool: return Path("default") in config.initrds -def finalize_historydir(args: Args) -> Path: +def finalize_historydir(args: Args, output_dir: Optional[Path] = None) -> Path: + # When an output directory is given, the build history is also stored there so that builds into + # distinct output directories don't read each other's history. Otherwise it lives in the config dir. + if output_dir is not None: + return output_dir / ".mkosi-private/history" + configdir = finalize_configdir(args.directory) return (configdir or Path.cwd()) / ".mkosi-private/history" @@ -5502,7 +5507,13 @@ def parse_config( return args, None, () configdir = finalize_configdir(args.directory) - historydir = finalize_historydir(args) + config_historydir = finalize_historydir(args) + # Prefer the history stored in the output directory (when --output-directory is given on the CLI) so a + # consumer reads back exactly the build that wrote into that directory. Fall back to the config dir for + # consumers that don't pass --output-directory and for history written by older mkosi versions. + historydir = finalize_historydir(args, context.cli.get("output_dir")) + if not (historydir / "latest.json").exists(): + historydir = config_historydir if have_history(args, historydir): history = Config.from_partial_json((historydir / "latest.json").read_text()) @@ -5555,8 +5566,15 @@ def parse_config( maincontext = copy.deepcopy(context) if config["history"] and want_new_history(args): - historydir.mkdir(parents=True, exist_ok=True) - (historydir / "latest.json").write_text(dump_json(Config.to_partial_dict(cli))) + # Store the history in the config dir (so consumers that don't pass --output-directory find it) and, + # when an output directory is configured, in the output directory too (so builds into distinct + # output directories stay isolated). These coincide when no output directory is set. This keys on + # the finalized output dir (config or CLI), unlike the read above which can only use the CLI value + # (the configuration isn't parsed yet there), so don't collapse the two into one variable. + latest_json = dump_json(Config.to_partial_dict(cli)) + for hd in [config_historydir, finalize_historydir(args, config.get("output_dir"))]: + hd.mkdir(parents=True, exist_ok=True) + (hd / "latest.json").write_text(latest_json) tools = None if config.get("tools_tree") in (Path("default"), Path("yes")): diff --git a/tests/test_config.py b/tests/test_config.py index a7c9124089..bdb5774332 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2024,3 +2024,36 @@ def test_history_found_via_configured_output_directory(tmp_path: Path) -> None: _, _, [config] = parse_config(["summary"]) assert config.image_id == "img-x" + + +def test_history_isolated_per_output_directory(tmp_path: Path) -> None: + d = tmp_path + + (d / "mkosi.conf").write_text( + """\ + [Distribution] + Distribution=fedora + + [Build] + History=yes + """ + ) + + out_a = d / "out-a" + out_b = d / "out-b" + + # Two builds into different output directories, each recording its own build history, distinguished + # by ImageId so we can tell which build a later consumer reads back. + with chdir(d): + parse_config(["--output-directory", os.fspath(out_a), "--image-id", "img-a", "build"]) + parse_config(["--output-directory", os.fspath(out_b), "--image-id", "img-b", "build"]) + + # A verb that consumes a previous build, pointed at output directory A, must read back the + # configuration of the build that wrote into A -- not whichever build happened to run last. + _, _, [config_last] = parse_config(["summary"]) + _, _, [config_a] = parse_config(["--output-directory", os.fspath(out_a), "summary"]) + _, _, [config_b] = parse_config(["--output-directory", os.fspath(out_b), "summary"]) + + assert config_last.image_id == "img-b" + assert config_a.image_id == "img-a" + assert config_b.image_id == "img-b" From b3ec53743ad3561e8ac05289bcee258515f1221e Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Fri, 26 Jun 2026 11:14:33 +0200 Subject: [PATCH 3/3] tests: Pass --output-directory to vm() and boot() The integration tests build into a per-test `--output-directory`, but the verbs that consume the build (vm, boot) did not pass it, so they recovered the build's configuration from the history in the config directory. This breaks parallel tests, as they read the history file from current global file (i.e. whichever build happened to finish last). This is the tests/__init__.py half of the reverted commit da49fe976. (That wasn't problematic.) --- tests/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 3e107a62e6..440d90a7c3 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -126,12 +126,13 @@ def boot(self, options: Sequence[str] = (), args: Sequence[str] = ()) -> Complet "--register=no", "--machine", self.machine, + "--output-directory", self.output_dir, *options, ], args, stdin=sys.stdin if sys.stdin.isatty() else None, check=False, - ) + ) # fmt: skip if result.returncode != 123: raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr) @@ -158,12 +159,13 @@ def vm( "--register=no", "--machine", self.machine, + "--output-directory", self.output_dir, *options, ], args, stdin=sys.stdin if sys.stdin.isatty() else None, check=False, - ) + ) # fmt: skip if result.returncode != 123: raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr)