Repository: osbuild/osbuild
Pull request #1863 (2024-08-21 17:08:39)

Some chroot context improvements
This PR addresses comments and suggestions made in #1860.

achilleas-k: Had to fix the dracut test and improved the chroot test based on https://github.com/osbuild/osbuild/pull/1863#discussion_r1731310912 (ty @mvo5).
thozza: ```
osbuild/util/chroot.py:48:9: PLW1510 `subprocess.run` without explicit `check` argument
```
(╯°□°）╯︵ ┻━┻
achilleas-k: But but ... 
```python
subprocess.run(cmd, **kwargs) # pylint: disable=subprocess-run-check
```
:thinking: 
achilleas-k: Ah, of course, `ruff` plays by its own rules.
achilleas-k: Had to add both `pylint:` and `noqa:` otherwise the other linter would complain when I did only one.

Patch:
diff --git a/osbuild.spec b/osbuild.spec
index 99cbdc86..1a4dcc2b 100644
--- a/osbuild.spec
+++ b/osbuild.spec
@@ -1,7 +1,7 @@
 %global         forgeurl https://github.com/osbuild/osbuild
 %global         selinuxtype targeted
 
-Version:        127
+Version:        128
 
 %forgemeta
 
diff --git a/osbuild/__init__.py b/osbuild/__init__.py
index 4e809843..88721cd0 100644
--- a/osbuild/__init__.py
+++ b/osbuild/__init__.py
@@ -10,7 +10,7 @@ independent of osbuild but used across the osbuild codebase.
 
 from .pipeline import Manifest, Pipeline, Stage
 
-__version__ = "127"
+__version__ = "128"
 
 __all__ = [
     "Manifest",
diff --git a/osbuild/util/chroot.py b/osbuild/util/chroot.py
index 661a7a8f..682988e5 100644
--- a/osbuild/util/chroot.py
+++ b/osbuild/util/chroot.py
@@ -2,7 +2,7 @@ import os
 import subprocess
 
 
-class ChrootProcDevSys:
+class Chroot:
     """
     Sets up mounts for the virtual filesystems inside a root tree, preparing it for running commands using chroot. This
     should be used whenever a stage needs to run a command against the root tree but doesn't support a --root option or
@@ -21,21 +21,29 @@ class ChrootProcDevSys:
                 print(f"Making missing chroot directory: {d}")
                 os.makedirs(self.root + d)
 
-        subprocess.check_call(["/usr/bin/mount",
-                               "-t", "proc",
-                               "-o", "nosuid,noexec,nodev",
-                               "proc", f"{self.root}/proc"])
-        subprocess.check_call(["/usr/bin/mount",
-                               "-t", "devtmpfs",
-                               "-o", "mode=0755,noexec,nosuid,strictatime",
-                               "devtmpfs", f"{self.root}/dev"])
-        subprocess.check_call(["/usr/bin/mount",
-                               "-t", "sysfs",
-                               "-o", "nosuid,noexec,nodev",
-                               "sysfs", f"{self.root}/sys"])
+        subprocess.run(["/usr/bin/mount", "-t", "proc", "-o", "nosuid,noexec,nodev",
+                        "proc", f"{self.root}/proc"],
+                       check=True)
+
+        subprocess.run(["/usr/bin/mount", "-t", "devtmpfs", "-o", "mode=0755,noexec,nosuid,strictatime",
+                        "devtmpfs", f"{self.root}/dev"],
+                       check=True)
+
+        subprocess.run(["/usr/bin/mount", "-t", "sysfs", "-o", "nosuid,noexec,nodev",
+                        "sysfs", f"{self.root}/sys"],
+                       check=True)
 
         return self
 
     def __exit__(self, exc_type, exc_value, tracebk):
+        failed_umounts = []
         for d in ["/proc", "/dev", "/sys"]:
-            subprocess.check_call(["/usr/bin/umount", self.root + d])
+            if subprocess.run(["/usr/bin/umount", "--lazy", self.root + d], check=False).returncode != 0:
+                failed_umounts.append(d)
+        if failed_umounts:
+            print(f"Error unmounting paths from chroot: {failed_umounts}")
+
+    def run(self, cmd, **kwargs):
+        cmd = ["/usr/sbin/chroot", self.root] + cmd
+        # pylint: disable=subprocess-run-check
+        subprocess.run(cmd, **kwargs)  # noqa: PLW1510
diff --git a/setup.py b/setup.py
index fa42bfa8..95c47475 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@ import setuptools
 
 setuptools.setup(
     name="osbuild",
-    version="127",
+    version="128",
     description="A build system for OS images",
     packages=["osbuild", "osbuild.formats", "osbuild.util","osbuild.solver"],
     license='Apache-2.0',
diff --git a/sources/org.osbuild.curl b/sources/org.osbuild.curl
index 8071dcba..817d4f06 100755
--- a/sources/org.osbuild.curl
+++ b/sources/org.osbuild.curl
@@ -100,7 +100,9 @@ SCHEMA = """
 # We are not just using %{json} here because older curl (7.76) will
 # write {"http_connect":000} which python cannot parse so we write our
 # own json subset
-CURL_WRITE_OUT = r'\{\"url\": \"%{url}\"\, \"filename_effective\": \"%{filename_effective}\", \"exitcode\": %{exitcode}, \"errormsg\": \"%{errormsg}\" \}\n'
+CURL_WRITE_OUT_FMT = r'\{\"url\": \"%{url}\"\, \"filename_effective\": \"%{filename_effective}\", \"exitcode\": %{exitcode}, \"errormsg\": \"%{errormsg}\" \}\n'
+
+NR_RETRYS = 10
 
 
 def curl_has_parallel_downloads():
@@ -108,12 +110,6 @@ def curl_has_parallel_downloads():
     Return true if curl has all the support we needed for parallel downloading
     (this include --write-out "%{json}" too
     """
-    # Re-enable this once the failures in
-    # https://github.com/osbuild/osbuild-composer/pull/4247
-    # are understood
-    if os.getenv("OSBUILD_SOURCES_CURL_USE_PARALLEL") not in ["1", "yes", "true"]:
-        return False
-
     output = subprocess.check_output(["curl", "--version"], universal_newlines=True)
     first_line = output.split("\n", maxsplit=1)[0]
     m = re.match(r'^curl (\d+\.\d+\.\d+)', first_line)
@@ -139,31 +135,37 @@ def _quote_url(url: str) -> str:
 
 def gen_curl_download_config(config_path: pathlib.Path, chksum_desc_tuple: List[Tuple[str, Dict]], parallel=False):
     with open(config_path, "w", encoding="utf8") as fp:
-        # global options
-        if parallel:
-            fp.write("parallel\n")
-        fp.write(textwrap.dedent(f"""\
+        # Because we use --next which resets the parser state we need to set
+        # these options for each url.
+        per_url_opts = textwrap.dedent(f"""\
         user-agent = "osbuild (Linux.{platform.machine()}; https://osbuild.org/)"
         silent
         speed-limit = 1000
         connect-timeout = 30
         fail
         location
-        """))
+        """)
         if parallel:
-            fp.write(textwrap.dedent(f"""\
-            write-out = "{CURL_WRITE_OUT}"
-            """))
-
+            per_url_opts += textwrap.dedent(f"""\
+            write-out = "{CURL_WRITE_OUT_FMT}"
+            """)
         proxy = os.getenv("OSBUILD_SOURCES_CURL_PROXY")
         if proxy:
-            fp.write(f'proxy = "{proxy}"\n')
-        fp.write("\n")
-        # per url options
-        for checksum, desc in chksum_desc_tuple:
+            per_url_opts += f'proxy = "{proxy}"\n'
+        # start with the global option(s)
+        if parallel:
+            fp.write(textwrap.dedent("""\
+            # global options
+            parallel
+
+            """))
+        # then generate the per-url config
+        fp.write("# per-url options\n")
+        for i, (checksum, desc) in enumerate(chksum_desc_tuple):
             url = _quote_url(desc.get("url"))
             fp.write(f'url = "{url}"\n')
             fp.write(f'output = "{checksum}"\n')
+            fp.write(f'{per_url_opts}')
             secrets = desc.get("secrets")
             if secrets:
                 ssl_ca_cert = secrets.get('ssl_ca_cert')
@@ -180,7 +182,8 @@ def gen_curl_download_config(config_path: pathlib.Path, chksum_desc_tuple: List[
                 fp.write('insecure\n')
             else:
                 fp.write('no-insecure\n')
-            fp.write("\n")
+            if i + 1 < len(chksum_desc_tuple):
+                fp.write("next\n\n")
 
 
 def try_parse_curl_line(line):
@@ -220,6 +223,7 @@ def fetch_many_new_curl(tmpdir, targetdir, dl_pairs):
         curl_p = subprocess.Popen(curl_command, encoding="utf-8", cwd=tmpdir, stdout=subprocess.PIPE)
         # ensure that curl is killed even if an unexpected exit happens
         cm.callback(curl_p.kill)
+        errors = []
         while True:
             line = curl_p.stdout.readline()
             # empty line means eof/process finished
@@ -229,10 +233,13 @@ def fetch_many_new_curl(tmpdir, targetdir, dl_pairs):
             if not dl_details:
                 continue
             url = dl_details['url']
-            # ignore individual download errors, the overall exit status will
-            # reflect them and the caller can retry
+            # Keep track of individual errors as curl will only report
+            # the last download operation success/failure via the global
+            # exit code. There is "--fail-early" but the downside of that
+            # is that abort all in progress downloads too.
             if dl_details["exitcode"] != 0:
                 print(f"WARNING: failed to download {url}: {dl_details['errormsg']}", file=sys.stderr)
+                errors.append(f'{url}: error code {dl_details["exitcode"]}')
                 continue
             # the way downloads are setup the filename is the expected hash
             # so validate now and move into place
@@ -251,7 +258,10 @@ def fetch_many_new_curl(tmpdir, targetdir, dl_pairs):
             print(f"Downloaded {url}")
         # return overall download status (this will be an error if any
         # transfer failed)
-        return curl_p.wait()
+        curl_exit_code = curl_p.wait()
+        if not errors and curl_exit_code > 0:
+            errors.append("curl exited non-zero but reported no errors")
+        return errors
 
 
 class CurlSource(sources.SourceService):
@@ -310,13 +320,13 @@ class CurlSource(sources.SourceService):
             # some mirrors are sometimes broken. retry manually, because we could be
             # redirected to a different, working, one on retry.
             return_code = 0
-            for _ in range(10):
-                return_code = fetch_many_new_curl(tmpdir, self.cache, dl_pairs)
-                if return_code == 0:
+            for _ in range(NR_RETRYS):
+                errors = fetch_many_new_curl(tmpdir, self.cache, dl_pairs)
+                if not errors:
                     break
             else:
-                failed_urls = ",".join([itm[1]["url"] for itm in dl_pairs])
-                raise RuntimeError(f"curl: error downloading {failed_urls}: error code {return_code}")
+                details = ",".join(errors)
+                raise RuntimeError(f"curl: error downloading {details}")
 
         if len(dl_pairs) > 0:
             raise RuntimeError(f"curl: finished with return_code {return_code} but {dl_pairs} left to download")
@@ -334,7 +344,7 @@ class CurlSource(sources.SourceService):
             # some mirrors are sometimes broken. retry manually, because we could be
             # redirected to a different, working, one on retry.
             return_code = 0
-            for _ in range(10):
+            for _ in range(NR_RETRYS):
                 curl_config_path = f"{tmpdir}/curl-config.txt"
                 gen_curl_download_config(curl_config_path, [(checksum, desc)])
                 curl = subprocess.run(
diff --git a/stages/org.osbuild.dracut b/stages/org.osbuild.dracut
index 44e3c9ca..05a56682 100755
--- a/stages/org.osbuild.dracut
+++ b/stages/org.osbuild.dracut
@@ -1,9 +1,8 @@
 #!/usr/bin/python3
-import subprocess
 import sys
 
 import osbuild.api
-from osbuild.util.chroot import ChrootProcDevSys
+from osbuild.util.chroot import Chroot
 
 
 def yesno(name: str, value: bool) -> str:
@@ -83,12 +82,8 @@ def main(tree, options):
         if initoverlayfs:
             initfs_bin = "/usr/bin/initoverlayfs-install"
 
-        with ChrootProcDevSys(tree):
-            subprocess.run(["/usr/sbin/chroot", tree, initfs_bin,
-                            "--no-hostonly",
-                            "--kver", kver]
-                           + opts,
-                           check=True)
+        with Chroot(tree) as chroot:
+            chroot.run([initfs_bin, "--no-hostonly", "--kver", kver] + opts, check=True)
 
     return 0
 
diff --git a/stages/org.osbuild.update-crypto-policies b/stages/org.osbuild.update-crypto-policies
index b174497b..c0ea8e32 100755
--- a/stages/org.osbuild.update-crypto-policies
+++ b/stages/org.osbuild.update-crypto-policies
@@ -1,23 +1,20 @@
 #!/usr/bin/python3
-import subprocess
 import sys
 
 from osbuild import api
-from osbuild.util.chroot import ChrootProcDevSys
+from osbuild.util.chroot import Chroot
 
 
 def main(tree, options):
     policy = options["policy"]
 
-    with ChrootProcDevSys(tree):
+    with Chroot(tree) as chroot:
         # update-crypto-polciies uses /proc/self/mountinfo to find and verify that fips paths have been mounted to their
         # expected locations by searching for the following path suffixes:
         #   /crypto-policies/default-fips-config
         #   /crypto-policies/back-ends/FIPS
-        cmd = ["/usr/sbin/chroot", tree,
-               "/usr/bin/update-crypto-policies", "--set", policy]
-
-        subprocess.run(cmd, check=True)
+        cmd = ["/usr/bin/update-crypto-policies", "--set", policy]
+        chroot.run(cmd, check=True)
 
     return 0
 


Test patch:
diff --git a/sources/test/test_curl_source.py b/sources/test/test_curl_source.py
index 3a508e06..4527b9b5 100644
--- a/sources/test/test_curl_source.py
+++ b/sources/test/test_curl_source.py
@@ -1,6 +1,7 @@
 #!/usr/bin/python3
 
 import hashlib
+import pathlib
 import platform
 import re
 import shutil
@@ -10,7 +11,7 @@ from unittest.mock import patch
 import pytest
 
 import osbuild.testutil
-from osbuild.testutil.net import http_serve_directory
+from osbuild.testutil.net import http_serve_directory, https_serve_directory
 
 SOURCES_NAME = "org.osbuild.curl"
 
@@ -101,10 +102,11 @@ def test_curl_download_many_fail(curl_parallel):
     }
     with pytest.raises(RuntimeError) as exp:
         curl_parallel.fetch_all(TEST_SOURCES)
-    assert str(exp.value) == 'curl: error downloading http://localhost:9876/random-not-exists: error code 7'
+    assert str(exp.value).startswith("curl: error downloading http://localhost:9876/random-not-exists")
+    assert 'http://localhost:9876/random-not-exists: error code 7' in str(exp.value)
 
 
-def make_test_sources(fake_httpd_root, port, n_files):
+def make_test_sources(fake_httpd_root, port, n_files, start_n=0, cacert=""):
     """
     Create test sources for n_file. All files have the names
     0,1,2...
@@ -113,13 +115,20 @@ def make_test_sources(fake_httpd_root, port, n_files):
     Returns a sources dict that can be used as input for "fetch_all()" with
     the correct hash/urls.
     """
+    proto = "https" if cacert else "http"
+
     fake_httpd_root.mkdir(exist_ok=True)
     sources = {}
-    for i in range(n_files):
+    for i in range(start_n, start_n + n_files):
         name = f"{i}"
-        sources[f"sha256:{hashlib.sha256(name.encode()).hexdigest()}"] = {
-            "url": f"http://localhost:{port}/{name}",
+        key = f"sha256:{hashlib.sha256(name.encode()).hexdigest()}"
+        val = {
+            "url": f"{proto}://localhost:{port}/{name}",
         }
+        if cacert:
+            val["secrets"] = {}
+            val["secrets"]["ssl_ca_cert"] = cacert
+        sources[key] = val
         (fake_httpd_root / name).write_text(name, encoding="utf8")
 
     return sources
@@ -161,9 +170,7 @@ def test_curl_download_many_chksum_validate(tmp_path, curl_parallel):
 
 
 @pytest.mark.parametrize("curl_parallel", [True, False], indirect=["curl_parallel"])
-def test_curl_download_many_retries(tmp_path, monkeypatch, curl_parallel):
-    monkeypatch.setenv("OSBUILD_SOURCES_CURL_USE_PARALLEL", "1")
-
+def test_curl_download_many_retries(tmp_path, curl_parallel):
     fake_httpd_root = tmp_path / "fake-httpd-root"
 
     with http_serve_directory(fake_httpd_root) as httpd:
@@ -253,17 +260,16 @@ def test_curl_gen_download_config_old_curl(tmp_path, sources_module):
 
     assert config_path.exists()
     assert config_path.read_text(encoding="utf8") == textwrap.dedent(f"""\
+    # per-url options
+    url = "http://example.com/file/0"
+    output = "sha256:5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
     user-agent = "osbuild (Linux.{platform.machine()}; https://osbuild.org/)"
     silent
     speed-limit = 1000
     connect-timeout = 30
     fail
     location
-
-    url = "http://example.com/file/0"
-    output = "sha256:5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
     no-insecure
-
     """)
 
 
@@ -273,34 +279,59 @@ def test_curl_gen_download_config_parallel(tmp_path, sources_module):
 
     assert config_path.exists()
     assert config_path.read_text(encoding="utf8") == textwrap.dedent(f"""\
+    # global options
     parallel
+
+    # per-url options
+    url = "http://example.com/file/0"
+    output = "sha256:5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
     user-agent = "osbuild (Linux.{platform.machine()}; https://osbuild.org/)"
     silent
     speed-limit = 1000
     connect-timeout = 30
     fail
     location
-    write-out = "{sources_module.CURL_WRITE_OUT}"
-
-    url = "http://example.com/file/0"
-    output = "sha256:5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
+    write-out = "{sources_module.CURL_WRITE_OUT_FMT}"
     no-insecure
+    next
 
     url = "http://example.com/file/1"
     output = "sha256:6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
+    user-agent = "osbuild (Linux.{platform.machine()}; https://osbuild.org/)"
+    silent
+    speed-limit = 1000
+    connect-timeout = 30
+    fail
+    location
+    write-out = "{sources_module.CURL_WRITE_OUT_FMT}"
     insecure
+    next
 
     url = "http://example.com/file/2"
     output = "sha256:d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35"
+    user-agent = "osbuild (Linux.{platform.machine()}; https://osbuild.org/)"
+    silent
+    speed-limit = 1000
+    connect-timeout = 30
+    fail
+    location
+    write-out = "{sources_module.CURL_WRITE_OUT_FMT}"
     cacert = "some-ssl_ca_cert"
     no-insecure
+    next
 
     url = "http://example.com/file/3"
     output = "sha256:4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce"
+    user-agent = "osbuild (Linux.{platform.machine()}; https://osbuild.org/)"
+    silent
+    speed-limit = 1000
+    connect-timeout = 30
+    fail
+    location
+    write-out = "{sources_module.CURL_WRITE_OUT_FMT}"
     cert = "some-ssl_client_cert"
     key = "some-ssl_client_key"
     no-insecure
-
     """)
 
 
@@ -322,14 +353,7 @@ Features: AsynchDNS IPv6 Largefile GSS-API Kerberos SPNEGO NTLM NTLM_WB SSL libz
 
 
 @patch("subprocess.check_output")
-def test_curl_has_parallel_download(mocked_check_output, monkeypatch, sources_module):
-    # by default, --parallel is disabled
-    mocked_check_output.return_value = NEW_CURL_OUTPUT
-    assert not sources_module.curl_has_parallel_downloads()
-
-    # unless this environemnt is set
-    monkeypatch.setenv("OSBUILD_SOURCES_CURL_USE_PARALLEL", "1")
-
+def test_curl_has_parallel_download(mocked_check_output, sources_module):
     mocked_check_output.return_value = NEW_CURL_OUTPUT
     assert sources_module.curl_has_parallel_downloads()
 
@@ -348,3 +372,32 @@ def test_curl_result_is_double_checked(tmp_path, curl_parallel):
         with pytest.raises(RuntimeError) as exp:
             curl_parallel.fetch_all(test_sources)
         assert re.match(r"curl: finished with return_code 0 but .* left to download", str(exp.value))
+
+
+@pytest.mark.parametrize("curl_parallel", [True, False], indirect=["curl_parallel"])
+def test_curl_download_many_mixed_certs(tmp_path, monkeypatch, sources_module, curl_parallel):
+    monkeypatch.setenv("OSBUILD_SOURCES_CURL_USE_PARALLEL", "1")
+    # ensure this does not accidentaly succeeds because we retry
+    monkeypatch.setattr(sources_module, "NR_RETRYS", 1)
+
+    fake_httpd_root = tmp_path / "fake-httpd-root"
+    git_root = pathlib.Path(__file__).parent.parent.parent
+    cert1_path = git_root / "test/data/certs/cert1.pem"
+    key1_path = git_root / "test/data/certs/key1.pem"
+    cert2_path = git_root / "test/data/certs/cert2.pem"
+    key2_path = git_root / "test/data/certs/key2.pem"
+
+    with https_serve_directory(fake_httpd_root, certfile=cert1_path, keyfile=key1_path) as httpds:
+        with https_serve_directory(fake_httpd_root, certfile=cert2_path, keyfile=key2_path) as httpds2:
+            test_sources = make_test_sources(
+                fake_httpd_root, httpds.server_port, 2, start_n=10, cacert=cert1_path)
+            test_sources.update(make_test_sources(
+                fake_httpd_root, httpds2.server_port, 2, cacert=cert2_path))
+            assert len(test_sources) == 4
+
+            curl_parallel.cache = tmp_path / "curl-download-dir"
+            curl_parallel.cache.mkdir()
+            curl_parallel.fetch_all(test_sources)
+
+            assert httpds.reqs.count == 2
+            assert httpds2.reqs.count == 2
diff --git a/stages/test/test_dracut.py b/stages/test/test_dracut.py
index 7e019f8b..c8c1d7ed 100644
--- a/stages/test/test_dracut.py
+++ b/stages/test/test_dracut.py
@@ -22,8 +22,9 @@ def test_dracut_with_initoverlayfs(mocked_run, tmp_path, stage_module, with_init
 
     stage_module.main(str(tmp_path), options)
 
-    assert len(mocked_run.call_args_list) == 1
-    args, kwargs = mocked_run.call_args_list[0]
+    # We expect 7 calls to run(): 3 mount + chroot + 3 umount
+    assert len(mocked_run.call_args_list) == 7
+    args, kwargs = mocked_run.call_args_list[3]  # chroot is the 4th call
     assert kwargs.get("check") is True
     run_argv = args[0]
     assert run_argv[0] == "/usr/sbin/chroot"
diff --git a/test/mod/test_util_chroot.py b/test/mod/test_util_chroot.py
new file mode 100644
index 00000000..1f4134bf
--- /dev/null
+++ b/test/mod/test_util_chroot.py
@@ -0,0 +1,42 @@
+#
+# Test for util/chroot.py
+#
+
+import os
+from unittest.mock import call, patch
+
+from osbuild.util.chroot import Chroot
+
+
+class RunReturn:
+    """
+    Class to be returned from mocked run() call so that the returncode is always 0.
+    """
+
+    @property
+    def returncode(self):
+        return 0
+
+
+@patch("subprocess.run", return_value=RunReturn())
+def test_chroot_context(mocked_run, tmp_path):
+
+    with Chroot(os.fspath(tmp_path)) as chroot:
+        chroot.run(["/bin/true"], check=True)
+        chroot.run(["/bin/false"], check=False)
+
+    assert mocked_run.call_args_list == [
+        call(["/usr/bin/mount", "-t", "proc", "-o", "nosuid,noexec,nodev",
+              "proc", os.fspath(tmp_path / "proc")], check=True),
+        call(["/usr/bin/mount", "-t", "devtmpfs", "-o", "mode=0755,noexec,nosuid,strictatime",
+              "devtmpfs", os.fspath(tmp_path / "dev")], check=True),
+        call(["/usr/bin/mount", "-t", "sysfs", "-o", "nosuid,noexec,nodev",
+              "sysfs", os.fspath(tmp_path / "sys")], check=True),
+
+        call(["/usr/sbin/chroot", os.fspath(tmp_path), "/bin/true"], check=True),
+        call(["/usr/sbin/chroot", os.fspath(tmp_path), "/bin/false"], check=False),
+
+        call(["/usr/bin/umount", "--lazy", os.fspath(tmp_path / "proc")], check=False),
+        call(["/usr/bin/umount", "--lazy", os.fspath(tmp_path / "dev")], check=False),
+        call(["/usr/bin/umount", "--lazy", os.fspath(tmp_path / "sys")], check=False),
+    ]