Repository: osbuild/osbuild
Pull request #1718 (2024-04-11 08:16:26)

test: tiny tweaks for the output handling in the test_depsolve.py 
tools: tweak repo_servers_fixture() to redirect stderr to  /dev/null

The default python http.server is very chatty, we don't need this
during the tests.

---
[PATCH] tools: tweak depsolve() helper to show stderr from depsolve

So that we see any error output during the tests in "realtime". With
subprocess check=True and capture_output=True on exit_code != 0 no
stderr as part of the exception by default so this change helps
seeing issues from depsolve-dnf more easily.



Patch:
diff --git a/mounts/org.osbuild.bind b/mounts/org.osbuild.bind
new file mode 100755
index 00000000..54d6f8d1
--- /dev/null
+++ b/mounts/org.osbuild.bind
@@ -0,0 +1,86 @@
+#!/usr/bin/python3
+"""
+Bind mount service
+
+Can (r)bind mount mounts to the tree.
+"""
+
+import os.path
+import subprocess
+import sys
+from typing import Dict
+from urllib.parse import urlparse
+
+from osbuild import mounts
+
+SCHEMA_2 = """
+"additionalProperties": false,
+"required": ["name", "type", "target"],
+"properties": {
+  "name": { "type": "string" },
+  "type": { "type": "string" },
+  "target": {
+    "type": "string",
+    "pattern": "^tree://"
+  },
+  "options": {
+    "required": ["source"],
+    "source": {
+      "type": "string",
+      "pattern": "^mount://"
+    }
+  }
+}
+"""
+
+
+def parse_location(location, tree, mountroot: str) -> str:
+    # we cannot use "osutil.util.parsing" here because it is too
+    # tightly coupled with how arguments for stages are passed
+    url = urlparse(location)
+    path = url.netloc
+    if url.scheme == "tree":
+        return os.path.join(tree, path.rstrip("/"))
+    if url.scheme == "mount":
+        return os.path.join(mountroot, path.rstrip("/"))
+    raise ValueError(f"unsupported schema {url.scheme} for {location}")
+
+
+class BindMount(mounts.MountService):
+
+    def __init__(self, args):
+        super().__init__(args)
+        self.mountpoint = ""
+
+    def mount(self, args: Dict):
+        tree = args["tree"]
+        mountroot = args["root"]
+        target = args["target"]
+        # we cannot use args["sources"] here because the osbuild code makes
+        # many assumptions about that it must link back to a "Device" so
+        # we follow the pattern from org.osbuild.ostree.deployment here
+        # and put it into "options"
+        options = args["options"]
+        source = parse_location(options.get("source"), tree, mountroot)
+        self.mountpoint = parse_location(target, tree, mountroot)
+        subprocess.run([
+            "mount",
+            "--rbind", source, self.mountpoint,
+        ], check=True)
+
+    def umount(self):
+        if self.mountpoint:
+            subprocess.run(["umount", "-R", "-v", self.mountpoint], check=True)
+            self.mountpoint = ""
+
+    def sync(self):
+        pass
+
+
+def main():
+    service = BindMount.from_args(sys.argv[1:])
+    service.main()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/osbuild/main_cli.py b/osbuild/main_cli.py
index 570e7a0c..28d5f740 100644
--- a/osbuild/main_cli.py
+++ b/osbuild/main_cli.py
@@ -187,10 +187,10 @@ def osbuild_cli() -> int:
                 stage_timeout=stage_timeout
             )
             if r["success"]:
-                monitor.log(f"manifest {args.manifest_path} finished successfully", origin="osbuild.main_cli")
+                monitor.log(f"manifest {args.manifest_path} finished successfully\n", origin="osbuild.main_cli")
             else:
                 # if we had monitor.error() we could use that here
-                monitor.log(f"manifest {args.manifest_path} failed", origin="osbuild.main_cli")
+                monitor.log(f"manifest {args.manifest_path} failed\n", origin="osbuild.main_cli")
 
             if r["success"] and exports:
                 for pid in exports:
@@ -205,7 +205,6 @@ def osbuild_cli() -> int:
                     for name, pl in manifest.pipelines.items():
                         print(f"{name + ':': <10}\t{pl.id}")
                 else:
-                    print()
                     print(f"{vt.reset}{vt.bold}{vt.red}Failed{vt.reset}")
 
             return 0 if r["success"] else 1
diff --git a/tools/osbuild-depsolve-dnf b/tools/osbuild-depsolve-dnf
index 15433d91..dbffdb59 100755
--- a/tools/osbuild-depsolve-dnf
+++ b/tools/osbuild-depsolve-dnf
@@ -27,6 +27,7 @@ class Solver():
         arch = request["arch"]
         releasever = request.get("releasever")
         module_platform_id = request["module_platform_id"]
+        proxy = request.get("proxy")
 
         arguments = request["arguments"]
         repos = arguments.get("repos", [])
@@ -62,8 +63,9 @@ class Solver():
         self.base.conf.cachedir = cache_dir
         self.base.conf.substitutions['arch'] = arch
         self.base.conf.substitutions['basearch'] = dnf.rpm.basearch(arch)
-        if releasever:
-            self.base.conf.substitutions['releasever'] = releasever
+        self.base.conf.substitutions['releasever'] = releasever
+        if proxy:
+            self.base.conf.proxy = proxy
 
         req_repo_ids = set()
         for repo in repos:
@@ -442,7 +444,11 @@ def validate_request(request):
             "kind": "InvalidRequest",
             "reason": "no 'module_platform_id' specified"
         }
-
+    if not request.get("releasever"):
+        return {
+            "kind": "InvalidRequest",
+            "reason": "no 'module_platform_id' specified"
+        }
     arguments = request.get("arguments")
     if not arguments:
         return {
@@ -456,16 +462,6 @@ def validate_request(request):
             "reason": "no 'repos' or 'root_dir' specified"
         }
 
-    # if root_dir is used, we also need releasever
-    # pylint: disable=fixme
-    # TODO: Make releasever mandatory in all cases.
-    # We temporarily keep it tied to root_dir for short-term backwards compatibility
-    if arguments.get("root_dir") and not request.get("releasever"):
-        return {
-            "kind": "InvalidRequest",
-            "reason": "'root_dir' requires setting 'releasever'"
-        }
-
     return None
 
 
diff --git a/tools/osbuild-depsolve-dnf5 b/tools/osbuild-depsolve-dnf5
index ff23280f..afac612e 100755
--- a/tools/osbuild-depsolve-dnf5
+++ b/tools/osbuild-depsolve-dnf5
@@ -2,7 +2,7 @@
 # pylint: disable=invalid-name
 
 """
-A JSON-based interface for depsolving using DNF.
+A JSON-based interface for depsolving using DNF5.
 
 Reads a request through stdin and prints the result to stdout.
 In case of error, a structured error is printed to stdout as well.
@@ -93,6 +93,7 @@ class Solver():
         arch = request["arch"]
         releasever = request.get("releasever")
         module_platform_id = request["module_platform_id"]
+        proxy = request.get("proxy")
 
         arguments = request["arguments"]
         repos = arguments.get("repos", [])
@@ -115,6 +116,8 @@ class Solver():
         self.base.get_vars().set("basearch", _BASEARCH_MAP[arch])
         if releasever:
             self.base.get_vars().set('releasever', releasever)
+        if proxy:
+            self.base.get_vars().set('proxy', proxy)
 
         # Enable fastestmirror to ensure we choose the fastest mirrors for
         # downloading metadata (when depsolving) and downloading packages.
@@ -567,7 +570,11 @@ def validate_request(request):
             "kind": "InvalidRequest",
             "reason": "no 'module_platform_id' specified"
         }
-
+    if not request.get("releasever"):
+        return {
+            "kind": "InvalidRequest",
+            "reason": "no 'module_platform_id' specified"
+        }
     arguments = request.get("arguments")
     if not arguments:
         return {
@@ -581,16 +588,6 @@ def validate_request(request):
             "reason": "no 'repos' or 'root_dir' specified"
         }
 
-    # if root_dir is used, we also need releasever
-    # pylint: disable=fixme
-    # TODO: Make releasever mandatory in all cases.
-    # We temporarily keep it tied to root_dir for short-term backwards compatibility
-    if arguments.get("root_dir") and not request.get("releasever"):
-        return {
-            "kind": "InvalidRequest",
-            "reason": "'root_dir' requires setting 'releasever'"
-        }
-
     return None
 
 
diff --git a/tox.ini b/tox.ini
index 18f5e3e5..fac477d2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -24,7 +24,7 @@ deps =
     requests
 
 setenv =
-    LINTABLES = osbuild/ assemblers/* devices/* inputs/*.* mounts/* runners/* sources/*.* stages/*.* inputs/test/*.py stages/test/*.py sources/test/*.py test/ tools/
+    LINTABLES = osbuild/ assemblers/* devices/* inputs/*.* mounts/*.* mounts/test/*.py runners/* sources/*.* stages/*.* inputs/test/*.py stages/test/*.py sources/test/*.py test/ tools/
     LINTABLES_EXCLUDES = "*.json,*.sh"
     LINTABLES_EXCLUDES_RE = ".*\.json$,.*\.sh"
     TYPEABLES = osbuild


Test patch:
diff --git a/mounts/test/conftest.py b/mounts/test/conftest.py
new file mode 100644
index 00000000..28f926b2
--- /dev/null
+++ b/mounts/test/conftest.py
@@ -0,0 +1,33 @@
+import os
+import pathlib
+from types import ModuleType
+
+import pytest
+
+from osbuild import mounts, testutil
+from osbuild.testutil.imports import import_module_from_path
+
+
+@pytest.fixture(name="mounts_module")
+def mounts_module_fixture(request: pytest.FixtureRequest) -> ModuleType:
+    """mounts_module is a fixture that imports a stage module by its name
+    defined in MOUNTS_NAME in the test module.
+    """
+    if not hasattr(request.module, "MOUNTS_NAME"):
+        raise ValueError("mounts_module fixture must be used in a test module that defines MOUNTS_NAME")
+
+    mounts_name = request.module.MOUNTS_NAME
+    caller_dir = pathlib.Path(request.node.fspath).parent
+    module_path = caller_dir.parent / mounts_name
+    return import_module_from_path("mounts", os.fspath(module_path))
+
+
+@pytest.fixture
+def mounts_service(mounts_module) -> ModuleType:
+    """mounts_service is a fixture that imports a mounts module by its name
+    defined in MOUNTS_NAME in the test module and returns a MountsService
+    """
+    service_cls = testutil.find_one_subclass_in_module(mounts_module, mounts.MountService)
+    fd = testutil.make_fake_service_fd()
+    srv_obj = service_cls.from_args(["--service-fd", str(fd)])
+    return srv_obj
diff --git a/mounts/test/test_bind.py b/mounts/test/test_bind.py
new file mode 100644
index 00000000..2a3c701e
--- /dev/null
+++ b/mounts/test/test_bind.py
@@ -0,0 +1,100 @@
+#!/usr/bin/python3
+
+import os
+import pathlib
+from unittest.mock import patch
+
+import pytest
+
+import osbuild.meta
+from osbuild import testutil
+
+MOUNTS_NAME = "org.osbuild.bind"
+
+
+@patch("subprocess.run")
+def test_bind_mount_simple(mocked_run, mounts_service):
+    fake_tree_path = "/tmp/osbuild/.osbuild/tmp/buildroot-tmp-_ym0rt5a/mounts/"
+    fake_mountroot_path = "/tmp/osbuild/.osbuild/stage/uuid-e7443c948dff406d88bcbb7658a31f0f/data/tree/"
+
+    fake_args = {
+        "tree": fake_tree_path,
+        "root": fake_mountroot_path,
+        "target": "tree://",
+        "options": {
+            "source": "mount://",
+        }
+    }
+    mounts_service.mount(fake_args)
+    assert len(mocked_run.call_args_list) == 1
+    args, kwargs = mocked_run.call_args_list[0]
+    assert args[0] == ["mount", "--rbind", fake_mountroot_path, fake_tree_path]
+    assert kwargs == {"check": True}
+
+
+@patch("subprocess.run")
+def test_bind_umount_simple(mocked_run, mounts_service):
+    # nothing mounted yet, nothing is done
+    mounts_service.umount()
+    assert len(mocked_run.call_args_list) == 0
+    # pretend a mount
+    mounts_service.mountpoint = "/something"
+    mounts_service.umount()
+    assert len(mocked_run.call_args_list) == 1
+    args, kwargs = mocked_run.call_args_list[0]
+    assert args[0] == ["umount", "-R", "-v", "/something"]
+    assert kwargs == {"check": True}
+    # mountpoint is cleared
+    assert mounts_service.mountpoint == ""
+    # ensure we do not umount twice
+    mounts_service.umount()
+    assert len(mocked_run.call_args_list) == 1
+
+
+@pytest.mark.parametrize("test_data,expected_err", [
+    # bad
+    ({}, "'name' is a required property"),
+    ({}, "'target' is a required property"),
+    ({}, "'source' is a required property"),
+    # good
+    ({"name": "bind", "target": "tree://", "options": {"source": "mount://"}}, ""),
+])
+def test_parameters_validation(test_data, expected_err):
+    root = pathlib.Path(__file__).parent.parent.parent
+    mod_info = osbuild.meta.ModuleInfo.load(root, "Mount", MOUNTS_NAME)
+    schema = osbuild.meta.Schema(mod_info.get_schema(), MOUNTS_NAME)
+    test_input = {
+        "type": MOUNTS_NAME,
+        "options": {}
+    }
+    test_input.update(test_data)
+    res = schema.validate(test_input)
+    if expected_err == "":
+        assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
+    else:
+        assert res.valid is False
+        testutil.assert_jsonschema_error_contains(res, expected_err)
+
+
+@pytest.mark.skipif(os.getuid() != 0, reason="needs root")
+def test_bind_mount_integration(tmp_path, mounts_service):
+    fake_tree_path = tmp_path / "mounts"
+    fake_tree_path.mkdir(parents=True, exist_ok=True)
+
+    fake_mountroot_path = tmp_path / "uuid-1234/data/tree"
+    fake_mountroot_path.mkdir(parents=True, exist_ok=True)
+    (fake_mountroot_path / "in-src-mnt").write_text("", encoding="utf8")
+
+    fake_args = {
+        "tree": fake_tree_path,
+        "root": fake_mountroot_path,
+        "target": "tree://",
+        "options": {
+            "source": "mount://",
+        }
+    }
+    assert not (fake_tree_path / "in-src-mnt").exists()
+    mounts_service.mount(fake_args)
+    assert (fake_tree_path / "in-src-mnt").exists()
+    mounts_service.umount()
+    assert not (fake_tree_path / "in-src-mnt").exists()
diff --git a/test/data/testrepos/README.md b/test/data/testrepos/README.md
index f521c54d..8772bcbf 100644
--- a/test/data/testrepos/README.md
+++ b/test/data/testrepos/README.md
@@ -7,16 +7,16 @@ Each subdirectory contains repository metadata that is served by a server during
 
 ```bash
 rpmdir=$(mktemp -d)
-cat <<EOF > "${rpmdir}/nothing.spec"
+cat <<EOF > "${rpmdir}/pkg-with-no-deps.spec"
 #----------- spec file starts ---------------
-Name:                   nothing
+Name:                   pkg-with-no-deps
 Version:                1.0.0
 Release:                0
 BuildArch:              noarch
 Vendor:                 noone
 Summary:                Provides %{name}
 License:                BSD
-Provides:               nothing
+Provides:               pkg-with-no-deps
 
 %description
 %{summary}
@@ -24,7 +24,7 @@ Provides:               nothing
 %files
 EOF
 
-rpmbuild --quiet --define "_topdir ${rpmdir}" -bb "${rpmdir}/nothing.spec"
+rpmbuild --quiet --define "_topdir ${rpmdir}" -bb "${rpmdir}/pkg-with-no-deps.spec"
 createrepo "${rpmdir}/RPMS/noarch/"
 mkdir -p ./test/data/testrepos/custom
 cp -a "${rpmdir}/RPMS/noarch/repodata" ./test/data/testrepos/custom
diff --git a/test/data/testrepos/custom/repodata/repomd.xml b/test/data/testrepos/custom/repodata/repomd.xml
index d100c73b..e0e007aa 100644
--- a/test/data/testrepos/custom/repodata/repomd.xml
+++ b/test/data/testrepos/custom/repodata/repomd.xml
@@ -1,28 +1,55 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <repomd xmlns="http://linux.duke.edu/metadata/repo" xmlns:rpm="http://linux.duke.edu/metadata/rpm">
-  <revision>1711036975</revision>
+  <revision>1712239547</revision>
   <data type="primary">
-    <checksum type="sha256">d13ac184dc585096c46f012180458480279d1ec0ce9382a574ab498cb2c55795</checksum>
-    <open-checksum type="sha256">9c599fb11fa679526ecb89070f9b878b124f9ca65b88a78e3af36cbc06452439</open-checksum>
-    <location href="repodata/d13ac184dc585096c46f012180458480279d1ec0ce9382a574ab498cb2c55795-primary.xml.zst"/>
-    <timestamp>1711036975</timestamp>
-    <size>569</size>
-    <open-size>1102</open-size>
+    <checksum type="sha256">31724d01e30b0e3aa99db0e108116932c1e652d9357cde29bb61c1d40032b1a7</checksum>
+    <open-checksum type="sha256">8cb804da6d5cb5547b4200886880c802d78d2aba5aaa87b4c11b72a2a44d0d71</open-checksum>
+    <location href="repodata/31724d01e30b0e3aa99db0e108116932c1e652d9357cde29bb61c1d40032b1a7-primary.xml.gz"/>
+    <timestamp>1712239547</timestamp>
+    <size>564</size>
+    <open-size>1164</open-size>
   </data>
   <data type="filelists">
-    <checksum type="sha256">4af4575ea55e40293989e81f9e3e9328a97c9c3b293cc69b02f6bd6cdb8f84c6</checksum>
-    <open-checksum type="sha256">828a8c2e757f3b1a51f09e48f342eedcc2eabf733e69ca6376266025d4de1325</open-checksum>
-    <location href="repodata/4af4575ea55e40293989e81f9e3e9328a97c9c3b293cc69b02f6bd6cdb8f84c6-filelists.xml.zst"/>
-    <timestamp>1711036975</timestamp>
-    <size>227</size>
-    <open-size>291</open-size>
+    <checksum type="sha256">e54003a8999ecdb08a48724ccc1b5252890d5f8924e0e1c5ca0b4235caebc2e8</checksum>
+    <open-checksum type="sha256">c0cee67acefd24e52209043e5371aeb26632b0fee6a893c5dc8aa8fed6c5d8d8</open-checksum>
+    <location href="repodata/e54003a8999ecdb08a48724ccc1b5252890d5f8924e0e1c5ca0b4235caebc2e8-filelists.xml.gz"/>
+    <timestamp>1712239547</timestamp>
+    <size>235</size>
+    <open-size>300</open-size>
   </data>
   <data type="other">
-    <checksum type="sha256">12f698f748c6bf69c336ea9299b6ad622c83cd3402eb3c37a9984ae65d318c8f</checksum>
-    <open-checksum type="sha256">2b30284c414f2d9ffc2f61f80b575b146cb39151f01cdcfb8736100873f82afe</open-checksum>
-    <location href="repodata/12f698f748c6bf69c336ea9299b6ad622c83cd3402eb3c37a9984ae65d318c8f-other.xml.zst"/>
-    <timestamp>1711036975</timestamp>
-    <size>225</size>
-    <open-size>287</open-size>
+    <checksum type="sha256">cfedfc89f8594ac0241fc1447e7521a7be3ffd1e46afee390d7f966a5a16ae7f</checksum>
+    <open-checksum type="sha256">bc80506f743a278c60a7480823af43d114bdd540a0e022bb8434a2fe94b174ed</open-checksum>
+    <location href="repodata/cfedfc89f8594ac0241fc1447e7521a7be3ffd1e46afee390d7f966a5a16ae7f-other.xml.gz"/>
+    <timestamp>1712239547</timestamp>
+    <size>234</size>
+    <open-size>296</open-size>
+  </data>
+  <data type="primary_db">
+    <checksum type="sha256">7eeb8dd6640fcc6ad9f72f57c29b55d42f9a1160cca99d899e27f08fe160b4f9</checksum>
+    <open-checksum type="sha256">7395c5ab568d723e6d2d2362af8995139e0684b66b1b5276ff5f41716a583ca0</open-checksum>
+    <location href="repodata/7eeb8dd6640fcc6ad9f72f57c29b55d42f9a1160cca99d899e27f08fe160b4f9-primary.sqlite.bz2"/>
+    <timestamp>1712239547</timestamp>
+    <size>1715</size>
+    <open-size>106496</open-size>
+    <database_version>10</database_version>
+  </data>
+  <data type="filelists_db">
+    <checksum type="sha256">9bc2044bec0de9b0ed1bda5a03c3c7348218691adce04d75d25563714533f116</checksum>
+    <open-checksum type="sha256">3de4900d06a729a1254abfd2be1ac47d158ef10fbfb3b3bf40ba03e51a0dccea</open-checksum>
+    <location href="repodata/9bc2044bec0de9b0ed1bda5a03c3c7348218691adce04d75d25563714533f116-filelists.sqlite.bz2"/>
+    <timestamp>1712239547</timestamp>
+    <size>690</size>
+    <open-size>28672</open-size>
+    <database_version>10</database_version>
+  </data>
+  <data type="other_db">
+    <checksum type="sha256">03836ef5b4baf5161a7fb1c7a13cfdc6774091ce5e971eb3bb6b64bf4e5afd0b</checksum>
+    <open-checksum type="sha256">f1cf95b0e8ce808d952325d45f2818a68e765556fde7a4247b8b91686f7010f9</open-checksum>
+    <location href="repodata/03836ef5b4baf5161a7fb1c7a13cfdc6774091ce5e971eb3bb6b64bf4e5afd0b-other.sqlite.bz2"/>
+    <timestamp>1712239547</timestamp>
+    <size>669</size>
+    <open-size>24576</open-size>
+    <database_version>10</database_version>
   </data>
 </repomd>
diff --git a/tools/test/test_depsolve.py b/tools/test/test_depsolve.py
index 063b9299..cf282bc9 100644
--- a/tools/test/test_depsolve.py
+++ b/tools/test/test_depsolve.py
@@ -2,8 +2,10 @@ import configparser
 import importlib
 import json
 import os
+import pathlib
 import socket
 import subprocess as sp
+import sys
 from tempfile import TemporaryDirectory
 
 import pytest
@@ -40,9 +42,7 @@ def depsolve(pkgs, repos, root_dir, cache_dir, command):
             ]
         }
     }
-    p = sp.run([command], input=json.dumps(req).encode(), check=True, capture_output=True)
-    if p.stderr:
-        print(p.stderr.decode())
+    p = sp.run([command], input=json.dumps(req).encode(), check=True, stdout=sp.PIPE, stderr=sys.stderr)
 
     return json.loads(p.stdout.decode())
 
@@ -59,7 +59,7 @@ def repo_servers_fixture():
     addresses = []
     for path in REPO_PATHS:
         port = get_rand_port()  # this is racy, but should be okay
-        p = sp.Popen(["python3", "-m", "http.server", str(port)], cwd=path, stdout=sp.PIPE)
+        p = sp.Popen(["python3", "-m", "http.server", str(port)], cwd=path, stdout=sp.PIPE, stderr=sp.DEVNULL)
         procs.append(p)
         # use last path component as name
         name = os.path.basename(path.rstrip("/"))
@@ -95,15 +95,15 @@ test_cases = [
         }
     },
     {
-        # "nothing" is the only package in the custom repo and has no dependencies
-        "packages": ["nothing"],
+        # "pkg-with-no-deps" is the only package in the custom repo and has no dependencies
+        "packages": ["pkg-with-no-deps"],
         "results": {
-            "packages": {"nothing"},
+            "packages": {"pkg-with-no-deps"},
             "reponames": {"custom"},
         },
     },
     {
-        "packages": ["filesystem", "nothing"],
+        "packages": ["filesystem", "pkg-with-no-deps"],
         "results": {
             "packages": {
                 "basesystem",
@@ -120,7 +120,7 @@ test_cases = [
                 "ncurses-libs",
                 "setup",
                 "tzdata",
-                "nothing",
+                "pkg-with-no-deps",
             },
             "reponames": {
                 "baseos",
@@ -129,7 +129,7 @@ test_cases = [
         },
     },
     {
-        "packages": ["tmux", "nothing"],
+        "packages": ["tmux", "pkg-with-no-deps"],
         "results": {
             "packages": {
                 "alternatives",
@@ -171,7 +171,7 @@ test_cases = [
                 "tmux",
                 "tzdata",
                 "zlib",
-                "nothing",
+                "pkg-with-no-deps",
             },
             "reponames": {
                 "baseos",
@@ -182,7 +182,23 @@ test_cases = [
 ]
 
 
-def config_combos(servers):
+def make_dnf_scafolding(base_dir):
+    root_dir = pathlib.Path(TemporaryDirectory(dir=base_dir).name)
+
+    repos_dir = root_dir / "etc/yum.repos.d"
+    repos_dir.mkdir(parents=True)
+    keys_dir = root_dir / "etc/pki/rpm-gpg"
+    keys_dir.mkdir(parents=True)
+    vars_dir = root_dir / "etc/dnf/vars"
+    vars_dir.mkdir(parents=True)
+
+    vars_path = vars_dir / "customvar"
+    vars_path.write_text(CUSTOMVAR, encoding="utf8")
+
+    return root_dir, repos_dir, keys_dir
+
+
+def config_combos(tmp_path, servers):
     """
     Return all configurations for the provided repositories, either as config files in a directory or as repository
     configs in the depsolve request, or a combination of both.
@@ -207,43 +223,31 @@ def config_combos(servers):
                 "rhsm": False,
                 "gpgkeys": [TEST_KEY + server["name"]],
             })
-        with TemporaryDirectory() as root_dir:
-            repos_dir = os.path.join(root_dir, "etc/yum.repos.d")
-            os.makedirs(repos_dir)
-            keys_dir = os.path.join(root_dir, "etc/pki/rpm-gpg")
-            os.makedirs(keys_dir)
-            vars_dir = os.path.join(root_dir, "etc/dnf/vars")
-            os.makedirs(vars_dir)
-
-            vars_path = os.path.join(vars_dir, "customvar")
-            with open(vars_path, "w", encoding="utf-8") as vars_file:
-                vars_file.write(CUSTOMVAR)
-
-            for idx in combo[1]:  # servers to be configured through root_dir
-                server = servers[idx]
-                name = server["name"]
-                # Use the gpgkey to test both the key reading and the variable substitution.
-                # For this test, it doesn't need to be a real key.
-                key_url = f"file:///etc/pki/rpm-gpg/RPM-GPG-KEY-$releasever-$basearch-$customvar-{name}"
-
-                key_path = os.path.join(keys_dir, f"RPM-GPG-KEY-{RELEASEVER}-{ARCH}-{CUSTOMVAR}-{name}")
-                with open(key_path, "w", encoding="utf-8") as key_file:
-                    key_file.write(TEST_KEY + name)
-                parser = configparser.ConfigParser()
-                parser.add_section(name)
-                # Set some options in a specific order in which they tend to be
-                # written in repo files.
-                parser.set(name, "name", name)
-                parser.set(name, "baseurl", server["address"])
-                parser.set(name, "enabled", "1")
-                parser.set(name, "gpgcheck", "1")
-                parser.set(name, "sslverify", "0")
-                parser.set(name, "gpgkey", key_url)
-
-                with open(f"{repos_dir}/{name}.repo", "w", encoding="utf-8") as repo_file:
-                    parser.write(repo_file, space_around_delimiters=False)
-
-            yield repo_configs, root_dir
+        root_dir, repos_dir, keys_dir = make_dnf_scafolding(tmp_path)
+        for idx in combo[1]:  # servers to be configured through root_dir
+            server = servers[idx]
+            name = server["name"]
+            # Use the gpgkey to test both the key reading and the variable substitution.
+            # For this test, it doesn't need to be a real key.
+            key_url = f"file:///etc/pki/rpm-gpg/RPM-GPG-KEY-$releasever-$basearch-$customvar-{name}"
+
+            key_path = keys_dir / f"RPM-GPG-KEY-{RELEASEVER}-{ARCH}-{CUSTOMVAR}-{name}"
+            key_path.write_text(TEST_KEY + name, encoding="utf8")
+            parser = configparser.ConfigParser()
+            parser.add_section(name)
+            # Set some options in a specific order in which they tend to be
+            # written in repo files.
+            parser.set(name, "name", name)
+            parser.set(name, "baseurl", server["address"])
+            parser.set(name, "enabled", "1")
+            parser.set(name, "gpgcheck", "1")
+            parser.set(name, "sslverify", "0")
+            parser.set(name, "gpgkey", key_url)
+
+            with (repos_dir / f"{name}.repo").open("w", encoding="utf-8") as fp:
+                parser.write(fp, space_around_delimiters=False)
+
+        yield repo_configs, os.fspath(root_dir)
 
 
 @pytest.fixture(name="cache_dir", scope="session")
@@ -252,10 +256,10 @@ def cache_dir_fixture(tmpdir_factory):
 
 
 @pytest.mark.parametrize("test_case", test_cases)
-def test_depsolve(repo_servers, test_case, cache_dir):
+def test_depsolve(tmp_path, repo_servers, test_case, cache_dir):
     pks = test_case["packages"]
 
-    for repo_configs, root_dir in config_combos(repo_servers):
+    for repo_configs, root_dir in config_combos(tmp_path, repo_servers):
         res = depsolve(pks, repo_configs, root_dir, cache_dir, "./tools/osbuild-depsolve-dnf")
         assert {pkg["name"] for pkg in res["packages"]} == test_case["results"]["packages"]
         assert res["repos"].keys() == test_case["results"]["reponames"]
@@ -265,10 +269,10 @@ def test_depsolve(repo_servers, test_case, cache_dir):
 
 @pytest.mark.skipif(not has_dnf5(), reason="libdnf5 not available")
 @pytest.mark.parametrize("test_case", test_cases)
-def test_depsolve_dnf5(repo_servers, test_case, cache_dir):
+def test_depsolve_dnf5(tmp_path, repo_servers, test_case, cache_dir):
     pks = test_case["packages"]
 
-    for repo_configs, repos_dir in config_combos(repo_servers):
+    for repo_configs, repos_dir in config_combos(tmp_path, repo_servers):
         res = depsolve(pks, repo_configs, repos_dir, cache_dir, "./tools/osbuild-depsolve-dnf5")
         assert {pkg["name"] for pkg in res["packages"]} == test_case["results"]["packages"]
         assert res["repos"].keys() == test_case["results"]["reponames"]