]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
Implement run_workspace_command() on top of bwrap()
authorDaan De Meyer <daan.j.demeyer@gmail.com>
Sun, 16 Jul 2023 16:09:01 +0000 (18:09 +0200)
committerDaan De Meyer <daan.j.demeyer@gmail.com>
Mon, 17 Jul 2023 11:05:48 +0000 (13:05 +0200)
Instead of duplicating the apivfs setup between bwrap() and
run_workspace_command(), let's make bwrap() responsible for setting
up the apivfs and implement chrooting on top of it. Specifically,
to chroot, we just run bwrap nested to do the chroot and any
additional setup before running the actual command. This is implemented
in the new chroot_cmd() function which returns the command to do the
chroot. This also sets the stage for running scripts on the host and
somehow providing the chroot_cmd() command to scripts to use themselves
to run something inside the root directory.

To keep --debug-shell working smoothly for scripts, we pass the chroot
command as a new extra argument to bwrap() so we can also apply it when
starting the debug shell.

We also rework the /etc/resolv.conf bind mounting so we don't have
to do any cleanup after the command finishes.

mkosi/__init__.py
mkosi/distributions/gentoo.py
mkosi/run.py

index 21c2b51579b50351a8f396ee896b1f4b95271436..1e46c985c447ead45f9abc16c5c55ba1474f087b 100644 (file)
@@ -41,9 +41,9 @@ from mkosi.run import (
     become_root,
     bwrap,
     bwrap_cmd,
+    chroot_cmd,
     fork_and_wait,
     run,
-    run_workspace_command,
     spawn,
 )
 from mkosi.state import MkosiState
@@ -310,31 +310,31 @@ def run_prepare_script(state: MkosiState, build: bool) -> None:
     if build and state.config.build_script is None:
         return
 
-    bwrap: list[PathString] = [
+    options: list[PathString] = [
         "--bind", state.config.prepare_script, "/work/prepare",
         "--chdir", "/work/src",
     ]
 
     for src, target in finalize_sources(state.config):
-        bwrap += ["--bind", src, Path("/") / target]
+        options += ["--bind", src, Path("/") / target]
 
     if build:
         with complete_step("Running prepare script in build overlay…"), mount_build_overlay(state):
-            run_workspace_command(
-                state.root,
+            bwrap(
                 ["/work/prepare", "build"],
-                network=True,
-                bwrap_params=bwrap,
+                tools=state.config.tools_tree,
+                apivfs=state.root,
+                extra=chroot_cmd(state.root, options=options, network=True),
                 env=dict(SRCDIR="/work/src") | state.environment,
             )
             shutil.rmtree(state.root / "work")
     else:
         with complete_step("Running prepare script…"):
-            run_workspace_command(
-                state.root,
+            bwrap(
                 ["/work/prepare", "final"],
-                network=True,
-                bwrap_params=bwrap,
+                tools=state.config.tools_tree,
+                apivfs=state.root,
+                extra=chroot_cmd(state.root, options=options, network=True),
                 env=dict(SRCDIR="/work/src") | state.environment,
             )
             shutil.rmtree(state.root / "work")
@@ -345,12 +345,17 @@ def run_postinst_script(state: MkosiState) -> None:
         return
 
     with complete_step("Running postinstall script…"):
-        bwrap: list[PathString] = [
-            "--bind", state.config.postinst_script, "/work/postinst",
-        ]
-
-        run_workspace_command(state.root, ["/work/postinst", "final"], bwrap_params=bwrap,
-                              network=state.config.with_network, env=state.environment)
+        bwrap(
+            ["/work/postinst", "final"],
+            tools=state.config.tools_tree,
+            apivfs=state.root,
+            extra=chroot_cmd(
+                state.root,
+                options=["--bind", state.config.postinst_script, "/work/postinst"],
+                network=state.config.with_network,
+            ),
+            env=state.environment,
+        )
 
         shutil.rmtree(state.root / "work")
 
@@ -1592,7 +1597,13 @@ def run_selinux_relabel(state: MkosiState) -> None:
     cmd = f"mkdir /tmp/relabel && mount --bind / /tmp/relabel && exec setfiles -m -r /tmp/relabel -F {fc} /tmp/relabel || exit $?"
 
     with complete_step(f"Relabeling files using {policy} policy"):
-        run_workspace_command(state.root, ["sh", "-c", cmd], env=state.environment)
+        bwrap(
+            cmd=["sh", "-c", cmd],
+            tools=state.config.tools_tree,
+            apivfs=state.root,
+            extra=chroot_cmd(state.root),
+            env=state.environment,
+        )
 
 
 def need_build_packages(config: MkosiConfig) -> bool:
@@ -1864,7 +1875,7 @@ def run_build_script(state: MkosiState) -> None:
             state.root.joinpath(target).mkdir(mode=0o755, exist_ok=True, parents=True)
 
     with complete_step("Running build script…"), mount_build_overlay(state, read_only=True):
-        bwrap: list[PathString] = [
+        options: list[PathString] = [
             "--bind", state.config.build_script, "/work/build-script",
             "--bind", state.install_dir, "/work/dest",
             "--bind", state.staging, "/work/out",
@@ -1872,7 +1883,7 @@ def run_build_script(state: MkosiState) -> None:
         ]
 
         for src, target in finalize_sources(state.config):
-            bwrap += ["--bind", src, Path("/") / target]
+            options += ["--bind", src, Path("/") / target]
 
         env = dict(
             WITH_DOCS=one_zero(state.config.with_docs),
@@ -1884,13 +1895,19 @@ def run_build_script(state: MkosiState) -> None:
         )
 
         if state.config.build_dir is not None:
-            bwrap += ["--bind", state.config.build_dir, "/work/build"]
+            options += ["--bind", state.config.build_dir, "/work/build"]
             env |= dict(BUILDDIR="/work/build")
 
         # build-script output goes to stdout so we can run language servers from within mkosi
         # build-scripts. See https://github.com/systemd/mkosi/pull/566 for more information.
-        run_workspace_command(state.root, ["/work/build-script"], network=state.config.with_network,
-                              bwrap_params=bwrap, stdout=sys.stdout, env=env | state.environment)
+        bwrap(
+            ["/work/build-script"],
+            tools=state.config.tools_tree,
+            apivfs=state.root,
+            extra=chroot_cmd(state.root, options=options, network=state.config.with_network),
+            env=env | state.environment,
+            stdout=sys.stdout,
+        )
 
 
 def setfacl(config: MkosiConfig, root: Path, uid: int, allow: bool) -> None:
index fef5e0a7154061ba072cc7c339759869fe1571a7..7b7fc8389b586e690f2186ccd505c94aa5cd93dc 100644 (file)
@@ -12,7 +12,7 @@ from mkosi.distributions import DistributionInstaller
 from mkosi.install import copy_path
 from mkosi.log import ARG_DEBUG, complete_step, die
 from mkosi.remove import unlink_try_hard
-from mkosi.run import bwrap, run_workspace_command
+from mkosi.run import bwrap, chroot_cmd
 from mkosi.state import MkosiState
 from mkosi.types import PathString
 
@@ -23,8 +23,7 @@ def invoke_emerge(
     options: Sequence[str] = (),
     env: Mapping[str, str] = {},
 ) -> None:
-    run_workspace_command(
-        state.cache_dir / "stage3",
+    bwrap(
         cmd=[
             "emerge",
             *packages,
@@ -47,13 +46,18 @@ def invoke_emerge(
             *(["--verbose", "--quiet=n", "--quiet-fail=n"] if ARG_DEBUG.get() else ["--quiet-build", "--quiet"]),
             *options,
         ],
-        bwrap_params=[
-            "--bind", state.root, "/tmp/mkosi-root",
-            "--bind", state.cache_dir / "binpkgs", "/var/cache/binpkgs",
-            "--bind", state.cache_dir / "distfiles", "/var/cache/distfiles",
-            "--bind", state.cache_dir / "repos", "/var/db/repos",
-        ],
-        network=True,
+        tools=state.config.tools_tree,
+        apivfs=state.cache_dir / "stage3",
+        extra=chroot_cmd(
+            root=state.cache_dir / "stage3",
+            options=[
+                "--bind", state.root, "/tmp/mkosi-root",
+                "--bind", state.cache_dir / "binpkgs", "/var/cache/binpkgs",
+                "--bind", state.cache_dir / "distfiles", "/var/cache/distfiles",
+                "--bind", state.cache_dir / "repos", "/var/db/repos",
+            ],
+            network=True,
+        ),
         env=dict(
             FEATURES=" ".join([
                 "getbinpkg",
@@ -141,11 +145,15 @@ class GentooInstaller(DistributionInstaller):
 
         copy_path(state.pkgmngr, stage3, preserve_owner=False, tools=state.config.tools_tree)
 
-        run_workspace_command(
-            stage3,
+        bwrap(
             cmd=["emerge-webrsync"],
-            bwrap_params=["--bind", state.cache_dir / "repos", "/var/db/repos"],
-            network=True,
+            tools=state.config.tools_tree,
+            apivfs=stage3,
+            extra=chroot_cmd(
+                stage3,
+                options=["--bind", state.cache_dir / "repos", "/var/db/repos"],
+                network=True,
+            ),
         )
 
         invoke_emerge(state, packages=["sys-apps/baselayout"], env={"USE": "build"})
index b36a6e279630e8021084102ae064b3d2a59024ed..dc2e3c7569f3a1594c3e5cf43542636c387f5df9 100644 (file)
@@ -10,7 +10,6 @@ import multiprocessing
 import os
 import pwd
 import queue
-import shutil
 import signal
 import subprocess
 import sys
@@ -224,6 +223,7 @@ def run(
     stdin: _FILE = None,
     stdout: _FILE = None,
     stderr: _FILE = None,
+    input: Optional[str] = None,
     env: Mapping[str, PathString] = {},
     log: bool = True,
     **kwargs: Any,
@@ -248,7 +248,7 @@ def run(
     if ARG_DEBUG.get():
         env["SYSTEMD_LOG_LEVEL"] = "debug"
 
-    if "input" in kwargs:
+    if input is not None:
         assert stdin is None  # stdin and input cannot be specified together
     elif stdin is None:
         stdin = subprocess.DEVNULL
@@ -259,6 +259,7 @@ def run(
                               stdin=stdin,
                               stdout=stdout,
                               stderr=stderr,
+                              input=input,
                               env=env,
                               **kwargs,
                               preexec_fn=foreground)
@@ -295,6 +296,7 @@ def spawn(
         logging.error(f"\"{' '.join(str(s) for s in cmdline)}\" returned non-zero exit code {e.returncode}.")
         raise e
 
+
 @contextlib.contextmanager
 def bwrap_cmd(
     *,
@@ -359,7 +361,10 @@ def bwrap_cmd(
             # Clean up some stuff that might get written by package manager post install scripts.
             if apivfs:
                 for f in ("var/lib/systemd/random-seed", "var/lib/systemd/credential.secret", "etc/machine-info"):
-                    apivfs.joinpath(f).unlink(missing_ok=True)
+                    # Using missing_ok=True still causes an OSError if the mount is read-only even if the
+                    # file doesn't exist so do an explicit exists() check first.
+                    if (apivfs / f).exists():
+                        (apivfs / f).unlink()
 
 
 def bwrap(
@@ -368,6 +373,7 @@ def bwrap(
     tools: Optional[Path] = None,
     apivfs: Optional[Path] = None,
     log: bool = True,
+    extra: Sequence[PathString] = (),
     # The following arguments are passed directly to run().
     stdin: _FILE = None,
     stdout: _FILE = None,
@@ -386,7 +392,7 @@ def bwrap(
 
         try:
             result = run(
-                [*bwrap, *cmd],
+                [*bwrap, *extra, *cmd],
                 text=True,
                 env=env,
                 log=False,
@@ -401,7 +407,7 @@ def bwrap(
                 logging.error(f"\"{' '.join(str(s) for s in cmd)}\" returned non-zero exit code {e.returncode}.")
             if ARG_DEBUG_SHELL.get():
                 run(
-                    [*bwrap, "sh"],
+                    [*bwrap, *extra, "sh"],
                     stdin=sys.stdin,
                     check=False,
                     env=env,
@@ -412,69 +418,37 @@ def bwrap(
         return result
 
 
-def run_workspace_command(
-    root: Path,
-    cmd: Sequence[PathString],
-    bwrap_params: Sequence[PathString] = (),
-    network: bool = False,
-    stdout: _FILE = None,
-    env: Mapping[str, PathString] = {},
-) -> CompletedProcess:
+def chroot_cmd(root: Path, *, options: Sequence[PathString] = (), network: bool = False) -> Sequence[PathString]:
     cmdline: list[PathString] = [
         "bwrap",
         "--unshare-ipc",
         "--unshare-pid",
         "--unshare-cgroup",
-        "--bind", root, "/",
-        "--tmpfs", "/run",
-        "--tmpfs", "/tmp",
-        "--dev", "/dev",
-        "--proc", "/proc",
-        "--ro-bind", "/sys", "/sys",
+        "--dev-bind", root, "/",
         "--die-with-parent",
-        *bwrap_params,
+        "--setenv", "container", "mkosi",
+        "--setenv", "SYSTEMD_OFFLINE", str(int(network)),
+        "--setenv", "HOME", "/",
+        "--setenv", "PATH", "/usr/bin:/usr/sbin",
+        *options,
     ]
 
-    resolve = root.joinpath("etc/resolv.conf")
-
-    tmp = Path(tempfile.NamedTemporaryFile(delete=False).name)
-    tmp.unlink()
-
     if network:
-        # Bubblewrap does not mount over symlinks and /etc/resolv.conf might be a symlink. Deal with this by
-        # temporarily moving the file somewhere else.
-        if resolve.is_symlink():
-            shutil.move(resolve, tmp)
-
-        # If we're using the host network namespace, use the same resolver
-        cmdline += ["--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf"]
+        resolve = Path("etc/resolv.conf")
+        if (root / resolve).is_symlink():
+            # For each component in the target path, bubblewrap will try to create it if it doesn't exist
+            # yet. If a component in the path is a dangling symlink, bubblewrap will end up calling
+            # mkdir(symlink) which obviously fails if multiple components of the dangling symlink path don't
+            # exist yet. As a workaround, we resolve the symlink ourselves so that bubblewrap will correctly
+            # create all missing components in the target path.
+            resolve = (root / resolve).readlink()
+
+        # If we're using the host network namespace, use the same resolver.
+        cmdline += ["--ro-bind", "/etc/resolv.conf", Path("/") / resolve]
     else:
         cmdline += ["--unshare-net"]
 
-    env = dict(
-        container="mkosi",
-        SYSTEMD_OFFLINE=str(int(network)),
-        HOME="/",
-        PATH="/usr/bin:/usr/sbin",
-    ) | env
-
-    with tempfile.TemporaryDirectory(dir="/var/tmp", prefix="mkosi-var-tmp") as var_tmp:
-        cmdline += [
-            "--bind", var_tmp, "/var/tmp",
-            "sh", "-c", "chmod 1777 /tmp /var/tmp /dev/shm && exec $0 \"$@\" || exit $?"
-        ]
-
-        try:
-            return run([*cmdline, *cmd], text=True, stdout=stdout, env=env, log=False)
-        except subprocess.CalledProcessError as e:
-            logging.error(f"\"{' '.join(str(s) for s in cmd)}\" returned non-zero exit code {e.returncode}.")
-            if ARG_DEBUG_SHELL.get():
-                run([*cmdline, "sh"], stdin=sys.stdin, check=False, env=env, log=False)
-            raise e
-        finally:
-            if tmp.is_symlink():
-                resolve.unlink(missing_ok=True)
-                shutil.move(tmp, resolve)
+    return cmdline
 
 
 class MkosiAsyncioThread(threading.Thread):