From: Daan De Meyer Date: Sun, 16 Jul 2023 16:09:01 +0000 (+0200) Subject: Implement run_workspace_command() on top of bwrap() X-Git-Tag: v15~76^2~6 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=724e09216e06a26ff1284bfa48d57a77f12a4ef4;p=thirdparty%2Fmkosi.git Implement run_workspace_command() on top of bwrap() 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. --- diff --git a/mkosi/__init__.py b/mkosi/__init__.py index 21c2b5157..1e46c985c 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -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: diff --git a/mkosi/distributions/gentoo.py b/mkosi/distributions/gentoo.py index fef5e0a71..7b7fc8389 100644 --- a/mkosi/distributions/gentoo.py +++ b/mkosi/distributions/gentoo.py @@ -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"}) diff --git a/mkosi/run.py b/mkosi/run.py index b36a6e279..dc2e3c756 100644 --- a/mkosi/run.py +++ b/mkosi/run.py @@ -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):