]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
Enable unprivileged image builds
authorDaan De Meyer <daan.j.demeyer@gmail.com>
Sun, 22 Jan 2023 17:29:49 +0000 (18:29 +0100)
committerDaan De Meyer <daan.j.demeyer@gmail.com>
Thu, 9 Feb 2023 08:48:06 +0000 (09:48 +0100)
To enable this, when doing a build, we unshare a user namespace
with it's own private set of uids/gids obtained using newuidmap
and newgidmap. We also map the current user to the last UID/GID
in the UID/GID range from /etc/subuid and /etc/subgid. Together
with unsharing the mount namespace, this allows us to do
unprivileged bind and overlay mounts.

Next, we replace all usages of systemd-nspawn during the image build
with bubblewrap. systemd-nspawn cannot run as an unprivileged user
yet so we use bubblewrap which can. bubblewrap can also be used to
setup a chroot environment with API VFS filesystems so we make use
of that to setup chroot environments and remove all our homegrown
logic for it. This allows us to significantly reduce the amount of
mounts we do in mkosi itself.

To further reduce the amount of mounts, we modify the invocations
of all package managers to specify the cache directory via the
relevant option instead of mounting the cache directory into the
chroot. For apt, to accomplish this, we switch from using
DPkg::Chroot-Directory to setting the "--root" option for each
invocation of dpkg so that dpkg can access files outside of the
chroot.

Finally, we remove some options which become obsolete with this
commit, --idmap, --chown and --nspawn-keep-unit.

We also remove --source-file-transfer, --source-file-transfer-final
and the corresponding symlink options. Instead, we default to mounting
source files into the build tree. In the future, we'll add virtiofsd
support to allow accessing source files in qemu VMs.

We also move stuff around and create a few new files to store
helpers to avoid circular imports. There's also a little bit of
refactoring and cleanup all around.

23 files changed:
.github/workflows/ci.yml
NEWS.md
action.yaml
mkosi.md
mkosi/__init__.py
mkosi/__main__.py
mkosi/backend.py
mkosi/distributions/arch.py
mkosi/distributions/centos.py
mkosi/distributions/debian.py
mkosi/distributions/fedora.py
mkosi/distributions/gentoo.py
mkosi/distributions/mageia.py
mkosi/distributions/openmandriva.py
mkosi/distributions/opensuse.py
mkosi/install.py
mkosi/log.py [new file with mode: 0644]
mkosi/manifest.py
mkosi/mounts.py
mkosi/run.py [new file with mode: 0644]
mkosi/types.py [new file with mode: 0644]
tests/test_backend.py
tests/test_parse_load_args.py

index 7f12ff6395b0dd9f5e59a6ee100a265f757c6ddb..43021c69e32dcdd8265ab2ce861411912f741f08 100644 (file)
@@ -113,7 +113,7 @@ jobs:
           - fedora
           - rocky
           - alma
-          - gentoo
+          # gentoo (see https://github.com/systemd/mkosi/pull/1313#issuecomment-1406277198)
           - opensuse
         format:
           - directory
@@ -202,7 +202,14 @@ jobs:
         EOF
 
     - name: Build ${{ matrix.distro }}/${{ matrix.format }}
-      run: sudo python3 -m mkosi build
+      run: python3 -m mkosi build
+
+    # systemd-resolved is enabled by default in Arch/Debian/Ubuntu (systemd default preset) but fails to
+    # start in a systemd-nspawn container with --private-users so we mask it out here to avoid CI failures.
+    # FIXME: Remove when Arch/Debian/Ubuntu ship systemd v253
+    - name: Mask systemd-resolved
+      if: matrix.format == 'directory'
+      run: sudo systemctl --root mkosi.output/${{ matrix.distro }}~*/image mask systemd-resolved
 
     - name: Boot ${{ matrix.distro }}/${{ matrix.format }} systemd-nspawn
       if: matrix.format == 'disk' || matrix.format == 'directory'
@@ -214,7 +221,7 @@ jobs:
 
     - name: Boot ${{ matrix.distro }}/${{ matrix.format }} UEFI
       if: matrix.format == 'disk'
-      run: sudo timeout -k 30 10m python3 -m mkosi qemu
+      run: timeout -k 30 10m python3 -m mkosi qemu
 
     - name: Check ${{ matrix.distro }}/${{ matrix.format }} UEFI
       if: matrix.format == 'disk' || matrix.format == 'directory'
diff --git a/NEWS.md b/NEWS.md
index 3a81fcebec53f2c0c922bc71b187349250bfbb27..1be4645983f770845235b77e3276d970726fa474 100644 (file)
--- a/NEWS.md
+++ b/NEWS.md
@@ -2,10 +2,6 @@
 
 ## v15
 
-- Rename `--no-chown` to `--chown` and set it to default to `True`, preserving
-  current behaviour.
-- Add `--idmap` option to run `--systemd-nspawn` with ID mapping support. Defaults
-  to `True`. `--idmap=no` can be used to prevent usage of ID mapping.
 - Migrated to systemd-repart. Many options are dropped in favor of specifying them directly
   in repart partition definition files:
     - Format=gpt_xxx options are replaced with a single "disk" options. Filesystem to use can now be specified with repart's Format= option
 - Removed default kernel command line arguments `rhgb`, `selinux=0` and `audit=0`.
 - Dropped --all and --all-directory as this functionality is better implemented by
   using a build system.
+- mkosi now builds images without needing root privileges.
+- Removed `--no-chown`, `--idmap` and `--nspawn-keep-unit` options as they were made obsolete by moving to
+  rootless builds.
+- Removed `--source-file-transfer`, `--source-file-transfer-final`, `--source-resolve-symlinks` and
+  `--source-resolve-symlinks-final` in favor of always mounting the source directory into the build image.
+  `--source-file-transfer-final` might be reimplemented in the future using virtiofsd.
 
 ## v14
 
index 45f7b14b572490bbe23bf9df3b9639b8d85e0601..4d368bc84981c90a6adce85de8f6e7d6af62e479 100644 (file)
@@ -26,7 +26,8 @@ runs:
         squashfs-tools \
         btrfs-progs \
         mtools \
-        python3-pefile
+        python3-pefile \
+        bubblewrap
 
       sudo pacman-key --init
       sudo pacman-key --populate archlinux
@@ -41,17 +42,13 @@ runs:
       sudo apt-get install libfdisk-dev
       git clone https://github.com/systemd/systemd --depth=1
       meson systemd/build systemd -Drepart=true -Defi=true
-      ninja -C systemd/build systemd-nspawn systemd-dissect systemd-repart systemd-analyze bootctl ukify
-      sudo ln -svf $PWD/systemd/build/systemd-nspawn /usr/bin/systemd-nspawn
-      sudo ln -svf $PWD/systemd/build/systemd-dissect /usr/bin/systemd-dissect
+      ninja -C systemd/build systemd-nspawn systemd-repart bootctl ukify
       sudo ln -svf $PWD/systemd/build/systemd-repart /usr/bin/systemd-repart
-      sudo ln -svf $PWD/systemd/build/systemd-analyze /usr/bin/systemd-analyze
       sudo ln -svf $PWD/systemd/build/bootctl /usr/bin/bootctl
       sudo ln -svf $PWD/systemd/build/ukify /usr/bin/ukify
-      systemd-nspawn --version
-      systemd-dissect --version
       systemd-repart --version
       bootctl --version
+      ukify --version
 
   - name: Install
     shell: bash
index 658a70ca2ea46379154141009dece28801801e8f..a59b7f185f164b95da2ee3534ca39f4f7db49feb 100644 (file)
--- a/mkosi.md
+++ b/mkosi.md
@@ -534,13 +534,6 @@ a boolean argument: either "1", "yes", or "true" to enable, or "0",
   image root, so any `CopyFiles=` source paths in partition definition files will
   be relative to the image root directory.
 
-`NoChown=`, `--no-chown`
-
-: By default, if `mkosi` is run inside a `sudo` environment all
-  generated artifacts have their UNIX user/group ownership changed to
-  the user which invoked `sudo`. With this option this may be turned
-  off and all generated files are owned by `root`.
-
 `TarStripSELinuxContext=`, `--tar-strip-selinux-context`
 
 : If running on a SELinux-enabled system (Fedora Linux, CentOS, Rocky Linux,
@@ -704,11 +697,8 @@ a machine ID.
 
 `BuildSources=`, `--build-sources=`
 
-: Takes a path to a source tree to copy into the development image, if
-  the build script is used. This only applies if a build script is
-  used, and defaults to the local directory. Use `SourceFileTransfer=`
-  to configure how the files are transferred from the host to the
-  container image.
+: Takes a path to a source tree to mount into the development image, if
+  the build script is used.
 
 `BuildDirectory=`, `--build-dir=`
 
@@ -792,33 +782,32 @@ a machine ID.
 : Takes a path to an executable that is used as build script for this
   image. If this option is used the build process will be two-phased
   instead of single-phased. The specified script is copied onto the
-  development image and executed inside an `systemd-nspawn` container
-  environment. If this option is not used, but the `mkosi.build` file
-  found in the local directory it is automatically used for this
-  purpose (also see the "Files" section below). Specify an empty value
-  to disable automatic detection.
+  development image and executed inside a namespaced chroot environment.
+  If this option is not used, but the `mkosi.build` file found in the
+  local directory it is automatically used for this purpose (also see
+  the "Files" section below). Specify an empty value to disable
+  automatic detection.
 
 `PrepareScript=`, `--prepare-script=`
 
 : Takes a path to an executable that is invoked inside the image right
   after installing the software packages. It is the last step before
   the image is cached (if incremental mode is enabled).  This script
-  is invoked inside a `systemd-nspawn` container environment, and thus
-  does not have access to host resources.  If this option is not used,
-  but an executable script `mkosi.prepare` is found in the local
-  directory, it is automatically used for this purpose. Specify an
-  empty value to disable automatic detection.
+  is invoked inside a namespaced chroot environment, and thus does not
+  have access to host resources.  If this option is not used, but an
+  executable script `mkosi.prepare` is found in the local directory, it
+  is automatically used for this purpose. Specify an empty value to
+  disable automatic detection.
 
 `PostInstallationScript=`, `--postinst-script=`
 
 : Takes a path to an executable that is invoked inside the final image
   right after copying in the build artifacts generated in the first
-  phase of the build. This script is invoked inside a `systemd-nspawn`
-  container environment, and thus does not have access to host
-  resources. If this option is not used, but an executable
-  `mkosi.postinst` is found in the local directory, it is
-  automatically used for this purpose. Specify an empty value to
-  disable automatic detection.
+  phase of the build. This script is invoked inside a namespaced chroot
+  environment, and thus does not have access to host resources. If this
+  option is not used, but an executable `mkosi.postinst` is found in the
+  local directory, it is automatically used for this purpose. Specify an
+  empty value to disable automatic detection.
 
 `FinalizeScript=`, `--finalize-script=`
 
@@ -832,38 +821,6 @@ a machine ID.
   automatically used for this purpose. Specify an empty value to
   disable automatic detection.
 
-`SourceFileTransfer=`, `--source-file-transfer=`
-
-: Configures how the source file tree (as configured with
-  `BuildSources=`) is transferred into the container image during the
-  first phase of the build. Takes one of `copy-all` (to copy all files
-  from the source tree), `copy-git-cached` (to copy only those files
-  `git ls-files --cached` lists), `copy-git-others` (to copy only
-  those files `git ls-files --others` lists), `mount` to bind mount
-  the source tree directly. Defaults to `copy-git-cached` if a `git`
-  source tree is detected, otherwise `copy-all`. When you specify
-  `copy-git-more`, it is the same as `copy-git-cached`, except it also
-  includes the `.git/` directory.
-
-`SourceFileTransferFinal=`, `--source-file-transfer-final=`
-
-: Same as `SourceFileTransfer=`, but for the final image instead of
-  the build image. Takes the same values as `SourceFileFransfer=`
-  except `mount`. By default, sources are not copied into the final
-  image.
-
-`SourceResolveSymlinks=`, `--source-resolve-symlinks`
-
-: If given, any symbolic links in the source file tree are resolved and the
-  file contents are copied to the build image. If not given, they are left as
-  symbolic links. This only applies if `SourceFileTransfer=` is `copy-all`.
-  Defaults to leaving them as symbolic links.
-
-`SourceResolveSymlinksFinal=`, `--source-resolve-symlinks-final`
-
-: Same as `SourceResolveSymlinks=`, but for the final image instead of
-  the build image.
-
 `WithNetwork=`, `--with-network`
 
 : When true, enables network connectivity while the build script
@@ -967,13 +924,6 @@ a machine ID.
 : Space-delimited list of additional arguments to pass when invoking
   qemu.
 
-`NspawnKeepUnit=`, `--nspawn-keep-unit`
-
-: When used, this option instructs underlying calls of systemd-nspawn to
-  use the current unit scope, instead of creating a dedicated transcient
-  scope unit for the containers. This option should be used when mkosi is
-  run by a service unit.
-
 `Netdev=`, `--netdev`
 
 : When used with the boot or qemu verbs, this option creates a virtual
@@ -1227,14 +1177,13 @@ local directory:
   image. The *development* image is used to build the project in the
   current working directory (the *source* tree). For that the whole
   directory is copied into the image, along with the `mkosi.build`
-  script. The script is then invoked inside the image (via
-  `systemd-nspawn`), with `$SRCDIR` pointing to the *source*
-  tree. `$DESTDIR` points to a directory where the script should place
-  any files generated it would like to end up in the *final*
-  image. Note that `make`/`automake`/`meson` based build systems
-  generally honor `$DESTDIR`, thus making it very natural to build
-  *source* trees from the build script. After the *development* image
-  was built and the build script ran inside of it, it is removed
+  script. The script is then invoked inside the image, with `$SRCDIR`
+  pointing to the *source* tree. `$DESTDIR` points to a directory where
+  the script should place any files generated it would like to end up
+  in the *final* image. Note that `make`/`automake`/`meson` based build
+  systems generally honor `$DESTDIR`, thus making it very natural to
+  build *source* trees from the build script. After the *development*
+  image was built and the build script ran inside of it, it is removed
   again. After that the *final* image is built, without any *source*
   tree or build script copied in. However, this time the contents of
   `$DESTDIR` are added into the image.
@@ -1574,7 +1523,7 @@ When not using distribution packages make sure to install the
 necessary dependencies. For example, on *Fedora Linux* you need:
 
 ```bash
-dnf install btrfs-progs apt debootstrap dosfstools mtools edk2-ovmf e2fsprogs squashfs-tools gnupg python3 tar xfsprogs xz zypper sbsigntools
+dnf install bubblewrap btrfs-progs apt debootstrap dosfstools mtools edk2-ovmf e2fsprogs squashfs-tools gnupg python3 tar xfsprogs xz zypper sbsigntools
 ```
 
 On Debian/Ubuntu it might be necessary to install the `ubuntu-keyring`,
@@ -1583,7 +1532,7 @@ in addition to `apt` and `debootstrap`, depending on what kind of distribution i
 you want to build. `debootstrap` on Debian only pulls in the Debian keyring
 on its own, and the version on Ubuntu only the one from Ubuntu.
 
-Note that the minimum required Python version is 3.7.
+Note that the minimum required Python version is 3.9.
 
 # REFERENCES
 * [Primary mkosi git repository on GitHub](https://github.com/systemd/mkosi/)
index ac749946f61ce1e1061510c31d98dcd596c634b1..82c80b35d551787533579c2e07f86ce8cdad484e 100644 (file)
@@ -4,8 +4,6 @@ import argparse
 import configparser
 import contextlib
 import crypt
-import ctypes
-import ctypes.util
 import dataclasses
 import datetime
 import errno
@@ -18,6 +16,7 @@ import math
 import os
 import platform
 import re
+import resource
 import shlex
 import shutil
 import string
@@ -29,50 +28,26 @@ import uuid
 from collections.abc import Iterable, Iterator, Sequence
 from pathlib import Path
 from textwrap import dedent, wrap
-from typing import (
-    TYPE_CHECKING,
-    Any,
-    BinaryIO,
-    Callable,
-    NoReturn,
-    Optional,
-    TextIO,
-    TypeVar,
-    Union,
-    cast,
-)
+from typing import Any, Callable, NoReturn, Optional, TextIO, TypeVar, Union, cast
 
 from mkosi.backend import (
-    ARG_DEBUG,
     Distribution,
     ManifestFormat,
     MkosiConfig,
-    MkosiException,
-    MkosiNotSupportedException,
-    MkosiPrinter,
     MkosiState,
     OutputFormat,
-    SourceFileTransfer,
     Verb,
-    chown_to_running_user,
+    current_user_uid_gid,
     detect_distribution,
-    die,
+    flatten,
+    format_rlimit,
     is_centos_variant,
     is_rpm_distribution,
-    mkdirp_chown_current_user,
-    nspawn_knows_arg,
-    nspawn_rlimit_params,
-    nspawn_version,
     patch_file,
     path_relative_to_cwd,
-    run,
-    run_workspace_command,
-    scandir_recursive,
     set_umask,
     should_compress_output,
-    spawn,
     tmp_dir,
-    warn,
 )
 from mkosi.install import (
     add_dropin_config,
@@ -81,9 +56,26 @@ from mkosi.install import (
     flock,
     install_skeleton_trees,
 )
+from mkosi.log import (
+    ARG_DEBUG,
+    MkosiException,
+    MkosiNotSupportedException,
+    MkosiPrinter,
+    die,
+    warn,
+)
 from mkosi.manifest import Manifest
-from mkosi.mounts import dissect_and_mount, mount_bind, mount_overlay, mount_tmpfs
+from mkosi.mounts import dissect_and_mount, mount_bind, mount_overlay, scandir_recursive
 from mkosi.remove import unlink_try_hard
+from mkosi.run import (
+    become_root,
+    fork_and_wait,
+    init_mount_namespace,
+    run,
+    run_workspace_command,
+    spawn,
+)
+from mkosi.types import PathString, TempDir
 
 complete_step = MkosiPrinter.complete_step
 color_error = MkosiPrinter.color_error
@@ -92,21 +84,8 @@ color_error = MkosiPrinter.color_error
 __version__ = "14"
 
 
-# These types are only generic during type checking and not at runtime, leading
-# to a TypeError during compilation.
-# Let's be as strict as we can with the description for the usage we have.
-if TYPE_CHECKING:
-    CompletedProcess = subprocess.CompletedProcess[Any]
-    TempDir = tempfile.TemporaryDirectory[str]
-else:
-    CompletedProcess = subprocess.CompletedProcess
-    TempDir = tempfile.TemporaryDirectory
-
-SomeIO = Union[BinaryIO, TextIO]
-PathString = Union[Path, str]
-
 MKOSI_COMMANDS_NEED_BUILD = (Verb.shell, Verb.boot, Verb.qemu, Verb.serve)
-MKOSI_COMMANDS_SUDO = (Verb.build, Verb.clean, Verb.shell, Verb.boot)
+MKOSI_COMMANDS_SUDO = (Verb.shell, Verb.boot)
 MKOSI_COMMANDS_CMDLINE = (Verb.build, Verb.shell, Verb.boot, Verb.qemu, Verb.ssh)
 
 DRACUT_SYSTEMD_EXTRAS = [
@@ -148,8 +127,6 @@ def print_running_cmd(cmdline: Iterable[PathString]) -> None:
     MkosiPrinter.print_step(" ".join(shlex.quote(str(x)) for x in cmdline) + "\n")
 
 
-CLONE_NEWNS = 0x00020000
-
 # EFI has its own conventions too
 EFI_ARCHITECTURES = {
     "x86_64": "x64",
@@ -160,17 +137,6 @@ EFI_ARCHITECTURES = {
 }
 
 
-def unshare(flags: int) -> None:
-    libc_name = ctypes.util.find_library("c")
-    if libc_name is None:
-        die("Could not find libc")
-    libc = ctypes.CDLL(libc_name, use_errno=True)
-
-    if libc.unshare(ctypes.c_int(flags)) != 0:
-        e = ctypes.get_errno()
-        raise OSError(e, os.strerror(e))
-
-
 def format_bytes(num_bytes: int) -> str:
     if num_bytes >= 1024 * 1024 * 1024:
         return f"{num_bytes/1024**3 :0.1f}G"
@@ -182,11 +148,6 @@ def format_bytes(num_bytes: int) -> str:
     return f"{num_bytes}B"
 
 
-@complete_step("Detaching namespace")
-def init_namespace() -> None:
-    unshare(CLONE_NEWNS)
-    run(["mount", "--make-rslave", "/"])
-
 
 def setup_workspace(config: MkosiConfig) -> TempDir:
     with complete_step("Setting up temporary workspace.", "Temporary workspace set up in {.name}") as output:
@@ -202,7 +163,7 @@ def setup_workspace(config: MkosiConfig) -> TempDir:
             while str(p).startswith(str(config.build_sources)):
                 p = p.parent
 
-            d = tempfile.TemporaryDirectory(dir=p, prefix=f"mkosi.{config.build_sources.name}.tmp")
+            d = tempfile.TemporaryDirectory(dir=p, prefix=f".mkosi.{config.build_sources.name}.tmp")
         output.append(d)
 
     return d
@@ -226,14 +187,6 @@ def mount_image(state: MkosiState, cached: bool) -> Iterator[None]:
             workdir = state.workspace / "workdir"
             workdir.mkdir()
             stack.enter_context(mount_overlay(base, state.root, workdir, state.root))
-        else:
-            # always have a root of the tree as a mount point so we can recursively unmount anything that
-            # ends up mounted there.
-            stack.enter_context(mount_bind(state.root))
-
-        # Make sure /tmp and /run are not part of the image
-        stack.enter_context(mount_tmpfs(state.root / "run"))
-        stack.enter_context(mount_tmpfs(state.root / "tmp"))
 
         if state.do_run_build_script and state.config.include_dir and not cached:
             stack.enter_context(mount_bind(state.config.include_dir, state.root / "usr/include"))
@@ -270,17 +223,6 @@ def configure_hostname(state: MkosiState, cached: bool) -> None:
             etc_hostname.write_text(state.config.hostname + "\n")
 
 
-@contextlib.contextmanager
-def mount_cache(state: MkosiState) -> Iterator[None]:
-    cache_paths = state.installer.cache_path()
-
-    # We can't do this in mount_image() yet, as /var itself might have to be created as a subvolume first
-    with complete_step("Mounting Package Cache", "Unmounting Package Cache"), contextlib.ExitStack() as stack:
-        for cache_path in cache_paths:
-            stack.enter_context(mount_bind(state.cache, state.root / cache_path))
-        yield
-
-
 def configure_dracut(state: MkosiState, cached: bool) -> None:
     if not state.config.bootable or state.do_run_build_script or cached:
         return
@@ -320,6 +262,7 @@ def prepare_tree(state: MkosiState, cached: bool) -> None:
         return
 
     with complete_step("Setting up basic OS tree…"):
+        state.root.mkdir(mode=0o755, exist_ok=True)
         # We need an initialized machine ID for the build & boot logic to work
         state.root.joinpath("etc").mkdir(mode=0o755, exist_ok=True)
         state.root.joinpath("etc/machine-id").write_text(f"{state.machine_id}\n")
@@ -330,11 +273,6 @@ def prepare_tree(state: MkosiState, cached: bool) -> None:
         state.root.joinpath("etc/kernel/install.conf").write_text("layout=bls\n")
 
 
-def flatten(lists: Iterable[Iterable[T]]) -> list[T]:
-    """Flatten a sequence of sequences into a single list."""
-    return list(itertools.chain.from_iterable(lists))
-
-
 def clean_paths(
         root: Path,
         globs: Sequence[str],
@@ -464,8 +402,7 @@ def install_distribution(state: MkosiState, cached: bool) -> None:
     if cached:
         return
 
-    with mount_cache(state):
-        state.installer.install(state)
+    state.installer.install(state)
 
 
 def remove_packages(state: MkosiState) -> None:
@@ -608,22 +545,8 @@ def configure_serial_terminal(state: MkosiState, cached: bool) -> None:
                           """)
 
 
-def nspawn_id_map_supported() -> bool:
-    if nspawn_version() < 252:
-        return False
-
-    ret = run(["systemd-analyze", "compare-versions", platform.release(), ">=", "5.12"], check=False)
-    return ret.returncode == 0
-
-
-def nspawn_params_for_build_sources(config: MkosiConfig, sft: SourceFileTransfer) -> list[str]:
-    params = ["--setenv=SRCDIR=/root/src",
-              "--chdir=/root/src"]
-    if sft == SourceFileTransfer.mount:
-        idmap_opt = ":rootidmap" if nspawn_id_map_supported() and config.idmap else ""
-        params += [f"--bind={config.build_sources}:/root/src{idmap_opt}"]
-
-    return params
+def cache_params(state: MkosiState, root: Path) -> list[PathString]:
+    return flatten(("--bind", state.config.cache_path, root / p) for p in state.installer.cache_path())
 
 
 def run_prepare_script(state: MkosiState, cached: bool) -> None:
@@ -634,24 +557,22 @@ def run_prepare_script(state: MkosiState, cached: bool) -> None:
 
     verb = "build" if state.do_run_build_script else "final"
 
-    with mount_cache(state), complete_step("Running prepare script…"):
-
-        # We copy the prepare script into the build tree. We'd prefer
-        # mounting it into the tree, but for that we'd need a good
-        # place to mount it to. But if we create that we might as well
-        # just copy the file anyway.
-
-        shutil.copy2(state.config.prepare_script, state.root / "root/prepare")
+    with complete_step("Running prepare script…"):
+        bwrap: list[PathString] = [
+            "--bind", state.config.build_sources, "/root/src",
+            "--bind", state.config.prepare_script, "/root/prepare",
+            *cache_params(state, Path("/")),
+            "--chdir", "/root/src",
+        ]
 
-        nspawn_params = nspawn_params_for_build_sources(state.config, SourceFileTransfer.mount)
-        run_workspace_command(state, ["/root/prepare", verb],
-                              network=True, nspawn_params=nspawn_params, env=state.environment)
+        run_workspace_command(state, ["/root/prepare", verb], network=True, bwrap_params=bwrap,
+                              env=dict(SRCDIR="/root/src"))
 
         srcdir = state.root / "root/src"
         if srcdir.exists():
-            os.rmdir(srcdir)
+            srcdir.rmdir()
 
-        os.unlink(state.root / "root/prepare")
+        state.root.joinpath("root/prepare").unlink()
 
 
 def run_postinst_script(state: MkosiState) -> None:
@@ -662,17 +583,15 @@ def run_postinst_script(state: MkosiState) -> None:
 
     verb = "build" if state.do_run_build_script else "final"
 
-    with mount_cache(state), complete_step("Running postinstall script…"):
-
-        # We copy the postinst script into the build tree. We'd prefer
-        # mounting it into the tree, but for that we'd need a good
-        # place to mount it to. But if we create that we might as well
-        # just copy the file anyway.
+    with complete_step("Running postinstall script…"):
+        bwrap: list[PathString] = [
+            "--bind", state.config.postinst_script, "/root/postinst",
+            *cache_params(state, Path("/")),
+        ]
 
-        shutil.copy2(state.config.postinst_script, state.root / "root/postinst")
+        run_workspace_command(state, ["/root/postinst", verb], bwrap_params=bwrap,
+                              network=state.config.with_network is True)
 
-        run_workspace_command(state, ["/root/postinst", verb],
-                              network=(state.config.with_network is True), env=state.environment)
         state.root.joinpath("root/postinst").unlink()
 
 
@@ -694,7 +613,7 @@ def install_boot_loader(state: MkosiState) -> None:
         return
 
     with complete_step("Installing boot loader…"):
-        run(["bootctl", "install", "--root", state.root], env={"SYSTEMD_ESP_PATH": "/boot"})
+        run(["bootctl", "install", "--root", state.root], env={"SYSTEMD_ESP_PATH": "/boot", **os.environ})
 
 
 def install_extra_trees(state: MkosiState) -> None:
@@ -707,7 +626,7 @@ def install_extra_trees(state: MkosiState) -> None:
     with complete_step("Copying in extra file trees…"):
         for tree in state.config.extra_trees:
             if tree.is_dir():
-                copy_path(tree, state.root)
+                copy_path(tree, state.root, preserve_owner=False)
             else:
                 # unpack_archive() groks Paths, but mypy doesn't know this.
                 # Pretend that tree is a str.
@@ -724,104 +643,6 @@ def chdir(directory: Path) -> Iterator[Path]:
         os.chdir(c)
 
 
-def copy_git_files(src: Path, dest: Path, *, source_file_transfer: SourceFileTransfer) -> None:
-    what_files = ["--exclude-standard", "--cached"]
-    if source_file_transfer == SourceFileTransfer.copy_git_others:
-        what_files += ["--others", "--exclude=.mkosi-*"]
-
-    uid = int(os.getenv("SUDO_UID", 0))
-
-    c = run(["git", "-C", src, "ls-files", "-z", *what_files], stdout=subprocess.PIPE, text=False, user=uid)
-    files = {x.decode("utf-8") for x in c.stdout.rstrip(b"\0").split(b"\0")}
-
-    # Add the .git/ directory in as well.
-    if source_file_transfer == SourceFileTransfer.copy_git_more:
-        top = os.path.join(src, ".git/")
-        for path, _, filenames in os.walk(top):
-            for filename in filenames:
-                fp = os.path.join(path, filename)  # full path
-                fr = os.path.join(".git/", fp.removeprefix(top))  # relative to top
-                files.add(fr)
-
-    # Get submodule files
-    c = run(["git", "-C", src, "submodule", "status", "--recursive"], stdout=subprocess.PIPE, text=True, user=uid)
-    submodules = {x.split()[1] for x in c.stdout.splitlines()}
-
-    # workaround for git ls-files returning the path of submodules that we will
-    # still parse
-    files -= submodules
-
-    for sm in submodules:
-        sm = Path(sm)
-        c = run(
-            ["git", "-C", src / sm, "ls-files", "-z"] + what_files,
-            stdout=subprocess.PIPE,
-            text=False,
-            user=uid,
-        )
-        files |= {sm / x.decode("utf-8") for x in c.stdout.rstrip(b"\0").split(b"\0")}
-        files -= submodules
-
-        # Add the .git submodule file well.
-        if source_file_transfer == SourceFileTransfer.copy_git_more:
-            files.add(os.path.join(sm, ".git"))
-
-    del c
-
-    dest.mkdir(exist_ok=True)
-
-    with chdir(src):
-        run(["cp", "--parents", "--archive", "--reflink=auto", *files, dest])
-
-
-def install_build_src(state: MkosiState) -> None:
-    if state.for_cache:
-        return
-
-    if state.do_run_build_script:
-        if state.config.build_script is not None:
-            with complete_step("Copying in build script…"):
-                copy_path(state.config.build_script, state.root / "root" / state.config.build_script.name)
-        else:
-            return
-
-    sft: Optional[SourceFileTransfer] = None
-    resolve_symlinks: bool = False
-    if state.do_run_build_script:
-        sft = state.config.source_file_transfer
-        resolve_symlinks = state.config.source_resolve_symlinks
-    else:
-        sft = state.config.source_file_transfer_final
-        resolve_symlinks = state.config.source_resolve_symlinks_final
-
-    if sft is None:
-        return
-
-    with complete_step("Copying in sources…"):
-        target = state.root / "root/src"
-
-        if sft in (
-            SourceFileTransfer.copy_git_others,
-            SourceFileTransfer.copy_git_cached,
-            SourceFileTransfer.copy_git_more,
-        ):
-            copy_git_files(state.config.build_sources, target, source_file_transfer=sft)
-        elif sft == SourceFileTransfer.copy_all:
-            ignore = shutil.ignore_patterns(
-                ".git",
-                ".mkosi-*",
-                "*.cache-pre-dev",
-                "*.cache-pre-inst",
-                f"{state.config.output_dir.name}/" if state.config.output_dir else "mkosi.output/",
-                f"{state.config.workspace_dir.name}/" if state.config.workspace_dir else "mkosi.workspace/",
-                f"{state.config.cache_path.name}/" if state.config.cache_path else "mkosi.cache/",
-                f"{state.config.build_dir.name}/" if state.config.build_dir else "mkosi.builddir/",
-                f"{state.config.include_dir.name}/" if state.config.include_dir else "mkosi.includedir/",
-                f"{state.config.install_dir.name}/" if state.config.install_dir else "mkosi.installdir/",
-            )
-            shutil.copytree(state.config.build_sources, target, symlinks=not resolve_symlinks, ignore=ignore)
-
-
 def install_build_dest(state: MkosiState) -> None:
     if state.do_run_build_script:
         return
@@ -832,7 +653,8 @@ def install_build_dest(state: MkosiState) -> None:
         return
 
     with complete_step("Copying in build tree…"):
-        copy_path(install_dir(state), state.root)
+        # The build is executed as a regular user, so we don't want to copy ownership in this scenario.
+        copy_path(install_dir(state), state.root, preserve_owner=False)
 
 
 def xz_binary() -> str:
@@ -1123,7 +945,7 @@ def secure_boot_configure_auto_enroll(state: MkosiState) -> None:
             )
 
 
-def compress_output(config: MkosiConfig, src: Path) -> None:
+def compress_output(config: MkosiConfig, src: Path, uid: int, gid: int) -> None:
     compress = should_compress_output(config)
 
     if not src.is_file():
@@ -1132,10 +954,10 @@ def compress_output(config: MkosiConfig, src: Path) -> None:
     if not compress:
         # If we shan't compress, then at least make the output file sparse
         with complete_step(f"Digging holes into output file {src}…"):
-            run(["fallocate", "--dig-holes", src])
+            run(["fallocate", "--dig-holes", src], user=uid, group=gid)
     else:
         with complete_step(f"Compressing output file {src}…"):
-            run(compressor_command(compress, src))
+            run(compressor_command(compress, src), user=uid, group=gid)
 
 
 def qcow2_output(state: MkosiState) -> None:
@@ -1222,15 +1044,31 @@ def calculate_bmap(state: MkosiState) -> None:
         run(cmdline)
 
 
+def acl_toggle_remove(root: Path, uid: int, *, allow: bool) -> None:
+    ret = run(
+        [
+            "setfacl",
+            "--physical",
+            "--modify" if allow else "--remove",
+            f"user:{uid}:rwx" if allow else f"user:{uid}",
+            "-",
+        ],
+        check=False,
+        text=True,
+        # Supply files via stdin so we don't clutter --debug run output too much
+        input="\n".join([str(root), *(e.path for e in cast(Iterator[os.DirEntry[str]], scandir_recursive(root)) if e.is_dir())])
+    )
+    if ret.returncode != 0:
+        warn("Failed to set ACLs, you'll need root privileges to remove some generated files/directories")
+
+
 def save_cache(state: MkosiState) -> None:
     cache = cache_tree_path(state.config, is_final_image=False) if state.do_run_build_script else cache_tree_path(state.config, is_final_image=True)
 
     with complete_step("Installing cache copy…", f"Installed cache copy {path_relative_to_cwd(cache)}"):
         unlink_try_hard(cache)
         shutil.move(state.root, cache)
-
-    if state.config.chown:
-        chown_to_running_user(cache)
+        acl_toggle_remove(cache, state.uid, allow=True)
 
 
 def dir_size(path: PathString) -> int:
@@ -1279,7 +1117,6 @@ def setup_package_cache(config: MkosiConfig, workspace: Path) -> Path:
         cache = workspace / "cache"
     else:
         cache = config.cache_path
-        mkdirp_chown_current_user(cache, chown=config.chown, mode=0o755)
 
     return cache
 
@@ -1485,9 +1322,8 @@ class CustomHelpFormatter(argparse.HelpFormatter):
         """
         lines = text.splitlines()
         subindent = '    ' if lines[0].endswith(':') else ''
-        return list(itertools.chain.from_iterable(wrap(line, width,
-                                                       break_long_words=False, break_on_hyphens=False,
-                                                       subsequent_indent=subindent) for line in lines))
+        return flatten(wrap(line, width, break_long_words=False, break_on_hyphens=False,
+                            subsequent_indent=subindent) for line in lines)
 
 
 class ArgumentParserMkosi(argparse.ArgumentParser):
@@ -1616,15 +1452,6 @@ def parse_compression(value: str) -> Union[str, bool]:
     return parse_boolean(value)
 
 
-def parse_source_file_transfer(value: str) -> Optional[SourceFileTransfer]:
-    if value == "":
-        return None
-    try:
-        return SourceFileTransfer(value)
-    except Exception as exp:
-        raise argparse.ArgumentTypeError(str(exp))
-
-
 def parse_base_packages(value: str) -> Union[str, bool]:
     if value == "conditional":
         return value
@@ -1852,20 +1679,6 @@ def create_parser() -> ArgumentParserMkosi:
     group.add_argument("--hostname", help="Set hostname")
     group.add_argument("--image-version", help="Set version for image")
     group.add_argument("--image-id", help="Set ID for image")
-    group.add_argument(
-        "--chown",
-        metavar="BOOL",
-        action=BooleanAction,
-        default=True,
-        help="When running with sudo, reassign ownership of the generated files to the original user",
-    )  # NOQA: E501
-    group.add_argument(
-        "--idmap",
-        metavar="BOOL",
-        action=BooleanAction,
-        default=True,
-        help="Use systemd-nspawn's rootidmap option for bind-mounted directories.",
-    )
     group.add_argument(
         "--tar-strip-selinux-context",
         metavar="BOOL",
@@ -2085,47 +1898,6 @@ def create_parser() -> ArgumentParserMkosi:
         type=script_path,
         metavar="PATH",
     )
-    group.add_argument(
-        "--source-file-transfer",
-        type=parse_source_file_transfer,
-        choices=[*list(SourceFileTransfer), None],
-        metavar="METHOD",
-        default=None,
-        help='\n'.join(('How to copy build sources to the build image:',
-                        *(f"'{k}': {v}" for k, v in SourceFileTransfer.doc().items()),
-                        '(default: copy-git-others if in a git repository, otherwise copy-all)')),
-    )
-    group.add_argument(
-        "--source-file-transfer-final",
-        type=parse_source_file_transfer,
-        choices=[*list(SourceFileTransfer), None],
-        metavar="METHOD",
-        default=None,
-        help='\n'.join(('How to copy build sources to the final image:',
-                        *(f"'{k}': {v}" for k, v in SourceFileTransfer.doc().items()
-                          if k != SourceFileTransfer.mount),
-                        '(default: None)')),
-    )
-    group.add_argument(
-        "--source-resolve-symlinks",
-        metavar="BOOL",
-        action=BooleanAction,
-        help=("If true, symbolic links in the build sources are followed and the "
-              "file contents copied to the build image. If false, they are left as "
-              "symbolic links. "
-              "Only applies if --source-file-transfer-final is set to 'copy-all'.\n"
-              "(default: false)"),
-    )
-    group.add_argument(
-        "--source-resolve-symlinks-final",
-        metavar="BOOL",
-        action=BooleanAction,
-        help=("If true, symbolic links in the build sources are followed and the "
-              "file contents copied to the final image. If false, they are left as "
-              "symbolic links in the final image. "
-              "Only applies if --source-file-transfer-final is set to 'copy-all'.\n"
-              "(default: false)"),
-    )
     group.add_argument(
         "--with-network",
         action=WithNetworkAction,
@@ -2214,12 +1986,6 @@ def create_parser() -> ArgumentParserMkosi:
         # arguments.
         help=argparse.SUPPRESS,
     )
-    group.add_argument(
-        "--nspawn-keep-unit",
-        metavar="BOOL",
-        action=BooleanAction,
-        help="If specified, underlying systemd-nspawn containers use the resources of the current unit.",
-    )
     group.add_argument(
         "--network-veth",     # Compatibility option
         dest="netdev",
@@ -2725,7 +2491,6 @@ def normalize_script(path: Optional[Path]) -> Optional[Path]:
 
 
 def load_args(args: argparse.Namespace) -> MkosiConfig:
-    global ARG_DEBUG
     ARG_DEBUG.update(args.debug)
 
     args_find_path(args, "nspawn_settings", "mkosi.nspawn")
@@ -2947,15 +2712,6 @@ def load_args(args: argparse.Namespace) -> MkosiConfig:
     if args.qemu_headless and not any("loglevel" in x for x in args.kernel_command_line):
         args.kernel_command_line.append("loglevel=4")
 
-    if args.source_file_transfer is None:
-        if os.path.exists(".git") or args.build_sources.joinpath(".git").exists():
-            args.source_file_transfer = SourceFileTransfer.copy_git_others
-        else:
-            args.source_file_transfer = SourceFileTransfer.copy_all
-
-    if args.source_file_transfer_final == SourceFileTransfer.mount and args.verb == Verb.qemu:
-        die("Sorry, --source-file-transfer-final=mount is not supported when booting in QEMU")
-
     if args.skip_final_phase and args.verb != Verb.build:
         die("--skip-final-phase can only be used when building an image using 'mkosi build'", MkosiNotSupportedException)
 
@@ -3216,8 +2972,6 @@ def print_summary(config: MkosiConfig) -> None:
         print("           Remove Packages:", line_join_list(config.remove_packages))
 
     print("             Build Sources:", config.build_sources)
-    print("      Source File Transfer:", none_to_none(config.source_file_transfer))
-    print("Source File Transfer Final:", none_to_none(config.source_file_transfer_final))
     print("           Build Directory:", none_to_none(config.build_dir))
     print("         Include Directory:", none_to_none(config.include_dir))
     print("         Install Directory:", none_to_none(config.install_dir))
@@ -3256,33 +3010,40 @@ def print_summary(config: MkosiConfig) -> None:
     print("                    Netdev:", yes_no(config.netdev))
 
 
-def make_output_dir(config: MkosiConfig) -> None:
+def make_output_dir(state: MkosiState) -> None:
     """Create the output directory if set and not existing yet"""
-    if config.output_dir is None:
+    if state.config.output_dir is None:
         return
 
-    mkdirp_chown_current_user(config.output_dir, chown=config.chown, mode=0o755)
+    run(["mkdir", "-p", state.config.output_dir], user=state.uid, group=state.gid)
 
 
-def make_build_dir(config: MkosiConfig) -> None:
+def make_build_dir(state: MkosiState) -> None:
     """Create the build directory if set and not existing yet"""
-    if config.build_dir is None:
+    if state.config.build_dir is None:
         return
 
-    mkdirp_chown_current_user(config.build_dir, chown=config.chown, mode=0o755)
+    run(["mkdir", "-p", state.config.build_dir], user=state.uid, group=state.gid)
 
 
-def make_cache_dir(config: MkosiConfig) -> None:
-    """Create the output directory if set and not existing yet"""
-    # TODO: mypy complains that having the same structure as above, makes  the
-    # return on None unreachable code. I can't see right now, why it *should* be
-    # unreachable, so invert the structure here to be on the safe side.
-    if config.cache_path is not None:
-        mkdirp_chown_current_user(config.cache_path, chown=config.chown, mode=0o755)
+def make_cache_dir(state: MkosiState) -> None:
+    """Create the cache directory if set and not existing yet"""
+    run(["mkdir", "-p", state.config.cache_path], user=state.uid, group=state.gid)
 
 
-def configure_ssh(state: MkosiState, cached: bool) -> None:
-    if state.do_run_build_script or not state.config.ssh:
+def make_install_dir(state: MkosiState) -> None:
+    # If no install directory is configured, it'll be located in the workspace which is owned by root in the
+    # userns so we have to run as the same user.
+    run(["mkdir", "-p", install_dir(state)],
+        user=state.uid if state.config.install_dir else 0,
+        group=state.gid if state.config.install_dir else 0)
+    # Make sure the install dir is always owned by the user running mkosi since the build will be running as
+    # the same user and needs to be able to write files here.
+    os.chown(install_dir(state), state.uid, state.gid)
+
+
+def configure_ssh(state: MkosiState) -> None:
+    if state.do_run_build_script or state.for_cache or not state.config.ssh:
         return
 
     if state.config.distribution in (Distribution.debian, Distribution.ubuntu):
@@ -3304,20 +3065,13 @@ def configure_ssh(state: MkosiState, cached: bool) -> None:
     else:
         unit = "sshd"
 
-    # We cache the enable sshd step but not the keygen step because it creates a separate file on the host
-    # which introduces non-trivial issue when trying to cache it.
-
-    if not cached:
-        run(["systemctl", "--root", state.root, "enable", unit])
-
-    if state.for_cache:
-        return
+    run(["systemctl", "--root", state.root, "enable", unit])
 
     authorized_keys = state.root / "root/.ssh/authorized_keys"
     if state.config.ssh_key:
-        copy_path(Path(f"{state.config.ssh_key}.pub"), authorized_keys)
+        copy_path(Path(f"{state.config.ssh_key}.pub"), authorized_keys, preserve_owner=False)
     elif state.config.ssh_agent is not None:
-        env = {"SSH_AUTH_SOCK": state.config.ssh_agent}
+        env = {"SSH_AUTH_SOCK": str(state.config.ssh_agent), **os.environ}
         result = run(["ssh-add", "-L"], env=env, text=True, stdout=subprocess.PIPE)
         authorized_keys.write_text(result.stdout)
     else:
@@ -3330,10 +3084,12 @@ def configure_ssh(state: MkosiState, cached: bool) -> None:
                 input="y\n",
                 text=True,
                 stdout=subprocess.DEVNULL,
+                user=state.uid,
+                group=state.gid,
             )
 
         authorized_keys.parent.mkdir(parents=True, exist_ok=True)
-        copy_path(p.with_suffix(".pub"), authorized_keys)
+        copy_path(p.with_suffix(".pub"), authorized_keys, preserve_owner=False)
         os.remove(p.with_suffix(".pub"))
 
     authorized_keys.chmod(0o600)
@@ -3411,6 +3167,7 @@ def reuse_cache_tree(state: MkosiState) -> bool:
 
     with complete_step(f"Basing off cached tree {cache}", "Copied cached tree"):
         copy_path(cache, state.root)
+        acl_toggle_remove(state.root, state.uid, allow=False)
 
     return True
 
@@ -3501,8 +3258,6 @@ def build_image(state: MkosiState, *, manifest: Optional[Manifest] = None) -> No
     if state.config.build_script is None and state.do_run_build_script:
         return
 
-    make_build_dir(state.config)
-
     cached = reuse_cache_tree(state)
     if state.for_cache and cached:
         return
@@ -3519,12 +3274,11 @@ def build_image(state: MkosiState, *, manifest: Optional[Manifest] = None) -> No
         configure_dracut(state, cached)
         configure_netdev(state, cached)
         run_prepare_script(state, cached)
-        install_build_src(state)
         install_build_dest(state)
         install_extra_trees(state)
         run_kernel_install(state, cached)
         install_boot_loader(state)
-        configure_ssh(state, cached)
+        configure_ssh(state)
         run_postinst_script(state)
         run_preset_all(state)
         secure_boot_configure_auto_enroll(state)
@@ -3573,75 +3327,55 @@ def run_build_script(state: MkosiState) -> None:
     if state.config.build_script is None:
         return
 
-    idmap_opt = ":rootidmap" if nspawn_id_map_supported() and state.config.idmap else ""
-
     with complete_step("Running build script…"):
-        os.makedirs(install_dir(state), mode=0o755, exist_ok=True)
-
-        with_network = 1 if state.config.with_network is True else 0
-
-        cmdline = [
-            "systemd-nspawn",
-            "--quiet",
-            f"--directory={state.root}",
-            f"--machine=mkosi-{uuid.uuid4().hex}",
-            "--as-pid2",
-            "--link-journal=no",
-            "--register=no",
-            f"--bind={install_dir(state)}:/root/dest{idmap_opt}",
-            f"--bind={state.var_tmp()}:/var/tmp{idmap_opt}",
-            f"--setenv=WITH_DOCS={one_zero(state.config.with_docs)}",
-            f"--setenv=WITH_TESTS={one_zero(state.config.with_tests)}",
-            f"--setenv=WITH_NETWORK={with_network}",
-            "--setenv=DESTDIR=/root/dest",
-            *nspawn_rlimit_params(),
+        # Bubblewrap creates bind mount point parent directories with restrictive permissions so we create
+        # the work directory outselves here.
+        state.root.joinpath("work").mkdir(mode=0o755)
+
+        bwrap: list[PathString] = [
+            "--bind", state.config.build_sources, "/work/src",
+            "--bind", state.config.build_script, f"/work/{state.config.build_script.name}",
+            "--bind", install_dir(state), "/work/dest",
+            "--chdir", "/work/src",
         ]
 
-        # TODO: Use --autopipe once systemd v247 is widely available.
-        console_arg = f"--console={'interactive' if sys.stdout.isatty() else 'pipe'}"
-        if nspawn_knows_arg(console_arg):
-            cmdline += [console_arg]
+        env = dict(
+            WITH_DOCS=one_zero(state.config.with_docs),
+            WITH_TESTS=one_zero(state.config.with_tests),
+            WITH_NETWORK=one_zero(state.config.with_network is True),
+            SRCDIR="/work/src",
+            DESTDIR="/work/dest",
+        )
 
         if state.config.config_path is not None:
-            cmdline += [
-                f"--setenv=MKOSI_CONFIG={state.config.config_path}",
-                f"--setenv=MKOSI_DEFAULT={state.config.config_path}"
-            ]
-
-        cmdline += nspawn_params_for_build_sources(state.config, state.config.source_file_transfer)
+            env |= dict(
+                MKOSI_CONFIG=str(state.config.config_path),
+                MKOSI_DEFAULT=str(state.config.config_path),
+            )
 
         if state.config.build_dir is not None:
-            cmdline += ["--setenv=BUILDDIR=/root/build",
-                        f"--bind={state.config.build_dir}:/root/build{idmap_opt}"]
+            bwrap += ["--bind", state.config.build_dir, "/work/build"]
+            env |= dict(BUILDDIR="/work/build")
 
         if state.config.include_dir is not None:
-            cmdline += [f"--bind={state.config.include_dir}:/usr/include{idmap_opt}"]
-
-        if state.config.with_network is True:
-            # If we're using the host network namespace, use the same resolver
-            cmdline += ["--bind-ro=/etc/resolv.conf"]
-        else:
-            cmdline += ["--private-network"]
-
-        if state.config.nspawn_keep_unit:
-            cmdline += ["--keep-unit"]
+            bwrap += ["--bind", state.config.include_dir, "/usr/include"]
 
-        cmdline += [f"--setenv={env}={value}" for env, value in state.environment.items()]
-
-        cmdline += [f"/root/{state.config.build_script.name}"]
-
-        # When we're building the image because it's required for another verb, any passed arguments are most
-        # likely intended for the target verb, and not for "build", so don't add them in that case.
+        cmd = ["setpriv", f"--reuid={state.uid}", f"--regid={state.gid}", "--clear-groups", f"/work/{state.config.build_script.name}"]
+        # When we're building the image because it's required for another verb, any passed arguments are
+        # most likely intended for the target verb, and not for "build", so don't add them in that case.
         if state.config.verb == Verb.build:
-            cmdline += state.config.cmdline
+            cmd += state.config.cmdline
+
+        # 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, cmd, network=state.config.with_network is True, bwrap_params=bwrap,
+                              stdout=sys.stdout, env=env)
 
-        # 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.
-        result = run(cmdline, stdout=sys.stdout, check=False)
-        if result.returncode != 0:
-            if "build-script" in ARG_DEBUG:
-                run(cmdline[:-1], check=False)
-            die(f"Build script returned non-zero exit code {result.returncode}.")
+        state.root.joinpath("work/dest").rmdir()
+        state.root.joinpath("work/src").rmdir()
+        state.root.joinpath("work/build").rmdir()
+        state.root.joinpath("work").joinpath(state.config.build_script.name).unlink()
+        state.root.joinpath("work").rmdir()
 
 
 def need_cache_trees(state: MkosiState) -> bool:
@@ -3667,27 +3401,32 @@ def remove_artifacts(state: MkosiState, for_cache: bool = False) -> None:
         unlink_try_hard(state.var_tmp())
 
 
-def build_stuff(config: MkosiConfig) -> None:
-    make_output_dir(config)
-    make_cache_dir(config)
+def build_stuff(uid: int, gid: int, config: MkosiConfig) -> None:
     workspace = setup_workspace(config)
     workspace_dir = Path(workspace.name)
     cache = setup_package_cache(config, workspace_dir)
 
+    state = MkosiState(
+        uid=uid,
+        gid=gid,
+        config=config,
+        workspace=workspace_dir,
+        cache=cache,
+        do_run_build_script=False,
+        machine_id=config.machine_id or uuid.uuid4().hex,
+        for_cache=False,
+    )
+
     manifest = Manifest(config)
 
+    make_output_dir(state)
+    make_cache_dir(state)
+    make_install_dir(state)
+    make_build_dir(state)
+
     # Make sure tmpfiles' aging doesn't interfere with our workspace
     # while we are working on it.
     with flock(workspace_dir):
-        state = MkosiState(
-            config=config,
-            workspace=workspace_dir,
-            cache=cache,
-            do_run_build_script=False,
-            machine_id=config.machine_id or uuid.uuid4().hex,
-            for_cache=False,
-        )
-
         # If caching is requested, then make sure we have cache trees around we can make use of
         if need_cache_trees(state):
 
@@ -3730,18 +3469,24 @@ def build_stuff(config: MkosiConfig) -> None:
         calculate_signature(state)
         save_manifest(state, manifest)
 
+        if state.config.cache_path:
+            acl_toggle_remove(state.config.cache_path, state.uid, allow=True)
+
         for p in state.config.output_paths():
             if state.staging.joinpath(p.name).exists():
                 shutil.move(state.staging / p.name, p)
+                if p != state.config.output or state.config.output_format != OutputFormat.directory:
+                    os.chown(p, state.uid, state.gid)
+                else:
+                    acl_toggle_remove(p, uid, allow=True)
                 if p in (state.config.output, state.config.output_split_kernel):
-                    compress_output(state.config, p)
-            if state.config.chown and p.exists():
-                chown_to_running_user(p)
+                    compress_output(state.config, p, uid=state.uid, gid=state.gid)
 
         for p in state.staging.iterdir():
             shutil.move(p, state.config.output.parent / p.name)
+            os.chown(state.config.output.parent / p.name, state.uid, state.gid)
             if p.name.startswith(state.config.output.name):
-                compress_output(state.config, p)
+                compress_output(state.config, p, uid=state.uid, gid=state.gid)
 
 
 def check_root() -> None:
@@ -3749,11 +3494,6 @@ def check_root() -> None:
         die("Must be invoked as root.")
 
 
-def check_native(config: MkosiConfig) -> None:
-    if not config.architecture_is_native() and config.build_script and nspawn_version() < 250:
-        die("Cannot (currently) override the architecture and run build commands")
-
-
 @contextlib.contextmanager
 def suppress_stacktrace() -> Iterator[None]:
     try:
@@ -3822,13 +3562,26 @@ def ensure_networkd(config: MkosiConfig) -> bool:
     return True
 
 
+def nspawn_knows_arg(arg: str) -> bool:
+    # Specify some extra incompatible options so nspawn doesn't try to boot a container in the current
+    # directory if it has a compatible layout.
+    return "unrecognized option" not in run(["systemd-nspawn", arg,
+                                            "--directory", "/dev/null", "--image", "/dev/null"],
+                                            stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False,
+                                            text=True).stderr
+
+
 def run_shell(config: MkosiConfig) -> None:
+    cmdline: list[PathString] = ["systemd-nspawn", "--quiet"]
+
     if config.output_format in (OutputFormat.directory, OutputFormat.subvolume):
-        target = f"--directory={config.output}"
-    else:
-        target = f"--image={config.output}"
+        cmdline += ["--directory", config.output]
 
-    cmdline = ["systemd-nspawn", "--quiet", target]
+        owner = os.stat(config.output).st_uid
+        if owner != 0:
+            cmdline += [f"--private-users={str(owner)}"]
+    else:
+        cmdline += ["--image", config.output]
 
     # If we copied in a .nspawn file, make sure it's actually honoured
     if config.nspawn_settings is not None:
@@ -3837,7 +3590,7 @@ def run_shell(config: MkosiConfig) -> None:
     if config.verb == Verb.boot:
         cmdline += ["--boot"]
     else:
-        cmdline += nspawn_rlimit_params()
+        cmdline += [f"--rlimit=RLIMIT_CORE={format_rlimit(resource.RLIMIT_CORE)}"]
 
         # Redirecting output correctly when not running directly from the terminal.
         console_arg = f"--console={'interactive' if sys.stdout.isatty() else 'pipe'}"
@@ -3852,12 +3605,7 @@ def run_shell(config: MkosiConfig) -> None:
         cmdline += ["--ephemeral"]
 
     cmdline += ["--machine", machine_name(config)]
-
-    if config.nspawn_keep_unit:
-        cmdline += ["--keep-unit"]
-
-    if config.source_file_transfer_final == SourceFileTransfer.mount:
-        cmdline += [f"--bind={config.build_sources}:/root/src", "--chdir=/root/src"]
+    cmdline += [f"--bind={config.build_sources}:/root/src", "--chdir=/root/src"]
 
     for k, v in config.credentials.items():
         cmdline += [f"--set-credential={k}:{v}"]
@@ -3872,7 +3620,16 @@ def run_shell(config: MkosiConfig) -> None:
         cmdline += ["--"]
         cmdline += config.cmdline
 
-    run(cmdline)
+    uid, _ = current_user_uid_gid()
+
+    if config.output_format == OutputFormat.directory:
+        acl_toggle_remove(config.output, uid, allow=False)
+
+    try:
+        run(cmdline)
+    finally:
+        if config.output_format == OutputFormat.directory:
+            acl_toggle_remove(config.output, uid, allow=True)
 
 
 def find_qemu_binary(config: MkosiConfig) -> str:
@@ -4353,7 +4110,10 @@ def run_verb(raw: argparse.Namespace) -> None:
             return generate_secure_boot_key(config)
 
         if config.verb == Verb.bump:
-            bump_image_version(config)
+            return bump_image_version(config)
+
+        if config.verb == Verb.summary:
+            return print_summary(config)
 
         if config.verb in MKOSI_COMMANDS_SUDO:
             check_root()
@@ -4365,16 +4125,19 @@ def run_verb(raw: argparse.Namespace) -> None:
                 check_outputs(config)
 
         if needs_build(config) or config.verb == Verb.clean:
-            check_root()
             unlink_output(config)
 
-        if config.verb == Verb.summary:
-            print_summary(config)
-
         if needs_build(config):
-            check_native(config)
-            init_namespace()
-            build_stuff(config)
+            def target() -> None:
+                # Get the user UID/GID either on the host or in the user namespace running the build
+                uid, gid = become_root() if os.getuid() != 0 else current_user_uid_gid()
+                init_mount_namespace()
+                build_stuff(uid, gid, config)
+
+            # We only want to run the build in a user namespace but not the following steps. Since we can't
+            # rejoin the parent user namespace after unsharing from it, let's run the build in a fork so that
+            # the main process does not leave its user namespace.
+            fork_and_wait(target)
 
             if config.auto_bump:
                 bump_image_version(config)
index c205157255dd69541cb5176ffc318d4cf25b8ff6..af8a69381e1c87399ca09bfbbdbaa07d0922f5a7 100644 (file)
@@ -8,7 +8,8 @@ from collections.abc import Iterator
 from subprocess import CalledProcessError
 
 from mkosi import parse_args, run_verb
-from mkosi.backend import MkosiException, die
+from mkosi.log import MkosiException, die
+from mkosi.run import excepthook
 
 
 @contextlib.contextmanager
@@ -36,4 +37,5 @@ def main() -> None:
 
 
 if __name__ == "__main__":
+    sys.excepthook = excepthook
     main()
index 86536b3493efd0d8b725051813066f3a70fddbc4..d2a8f99abc4ed8748b3449b625d5f86b74949ae3 100644 (file)
@@ -2,49 +2,29 @@
 
 import argparse
 import ast
-import collections
 import contextlib
 import dataclasses
 import enum
 import functools
 import importlib
+import itertools
 import os
 import platform
 import pwd
 import re
 import resource
-import shlex
 import shutil
-import signal
-import subprocess
 import sys
 import tarfile
-import uuid
-from collections.abc import Iterable, Iterator, Mapping, Sequence
+from collections.abc import Iterable, Iterator, Sequence
 from pathlib import Path
-from types import FrameType
-from typing import (
-    IO,
-    TYPE_CHECKING,
-    Any,
-    Callable,
-    Deque,
-    NoReturn,
-    Optional,
-    TypeVar,
-    Union,
-    cast,
-)
+from typing import Any, Callable, Optional, TypeVar, Union, cast
 
 from mkosi.distributions import DistributionInstaller
+from mkosi.log import MkosiException, die
 
 T = TypeVar("T")
 V = TypeVar("V")
-PathString = Union[Path, str]
-
-
-def shell_join(cmd: Sequence[PathString]) -> str:
-    return " ".join(shlex.quote(str(x)) for x in cmd)
 
 
 @contextlib.contextmanager
@@ -67,29 +47,6 @@ def roundup(x: int, step: int) -> int:
     return ((x + step - 1) // step) * step
 
 
-# These types are only generic during type checking and not at runtime, leading
-# to a TypeError during compilation.
-# Let's be as strict as we can with the description for the usage we have.
-if TYPE_CHECKING:
-    CompletedProcess = subprocess.CompletedProcess[Any]
-    Popen = subprocess.Popen[Any]
-else:
-    CompletedProcess = subprocess.CompletedProcess
-    Popen = subprocess.Popen
-
-
-class MkosiException(Exception):
-    """Leads to sys.exit"""
-
-
-class MkosiNotSupportedException(MkosiException):
-    """Leads to sys.exit when an invalid combination of parsed arguments happens"""
-
-
-# This global should be initialized after parsing arguments
-ARG_DEBUG: set[str] = set()
-
-
 class Parseable:
     "A mix-in to provide conversions for argparse"
 
@@ -244,27 +201,6 @@ def is_centos_variant(d: Distribution) -> bool:
     )
 
 
-class SourceFileTransfer(enum.Enum):
-    copy_all = "copy-all"
-    copy_git_cached = "copy-git-cached"
-    copy_git_others = "copy-git-others"
-    copy_git_more = "copy-git-more"
-    mount = "mount"
-
-    def __str__(self) -> str:
-        return self.value
-
-    @classmethod
-    def doc(cls) -> dict["SourceFileTransfer", str]:
-        return {
-            cls.copy_all: "normal file copy",
-            cls.copy_git_cached: "use git ls-files --cached, ignoring any file that git itself ignores",
-            cls.copy_git_others: "use git ls-files --others, ignoring any file that git itself ignores",
-            cls.copy_git_more: "use git ls-files --cached, ignoring any file that git itself ignores, but include the .git/ directory",
-            cls.mount: "bind mount source files into the build image",
-        }
-
-
 class OutputFormat(Parseable, enum.Enum):
     directory = enum.auto()
     subvolume = enum.auto()
@@ -337,8 +273,6 @@ class MkosiConfig:
     image_version: Optional[str]
     image_id: Optional[str]
     hostname: Optional[str]
-    chown: bool
-    idmap: bool
     tar_strip_selinux_context: bool
     incremental: bool
     cache_initrd: bool
@@ -363,10 +297,6 @@ class MkosiConfig:
     prepare_script: Optional[Path]
     postinst_script: Optional[Path]
     finalize_script: Optional[Path]
-    source_file_transfer: SourceFileTransfer
-    source_file_transfer_final: Optional[SourceFileTransfer]
-    source_resolve_symlinks: bool
-    source_resolve_symlinks_final: bool
     with_network: Union[bool, str]
     nspawn_settings: Optional[Path]
     base_image: Optional[Path]
@@ -401,9 +331,6 @@ class MkosiConfig:
     qemu_kvm: bool
     qemu_args: Sequence[str]
 
-    # systemd-nspawn specific options
-    nspawn_keep_unit: bool
-
     passphrase: Optional[Path]
 
     def architecture_is_native(self) -> bool:
@@ -464,6 +391,8 @@ def build_auxiliary_output_path(args: Union[argparse.Namespace, MkosiConfig], su
 class MkosiState:
     """State related properties."""
 
+    uid: int
+    gid: int
     config: MkosiConfig
     workspace: Path
     cache: Path
@@ -525,15 +454,6 @@ def workspace(root: Path) -> Path:
     return root.parent
 
 
-def nspawn_knows_arg(arg: str) -> bool:
-    # Specify some extra incompatible options so nspawn doesn't try to boot a container in the current
-    # directory if it has a compatible layout.
-    return "unrecognized option" not in run(["systemd-nspawn", arg,
-                                            "--directory", "/dev/null", "--image", "/dev/null"],
-                                            stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False,
-                                            text=True).stderr
-
-
 def format_rlimit(rlimit: int) -> str:
     limits = resource.getrlimit(rlimit)
     soft = "infinity" if limits[0] == resource.RLIM_INFINITY else str(limits[0])
@@ -541,155 +461,6 @@ def format_rlimit(rlimit: int) -> str:
     return f"{soft}:{hard}"
 
 
-def nspawn_rlimit_params() -> Sequence[str]:
-    return [
-        f"--rlimit=RLIMIT_CORE={format_rlimit(resource.RLIMIT_CORE)}",
-    ] if nspawn_knows_arg("--rlimit") else []
-
-
-def nspawn_version() -> int:
-    return int(run(["systemd-nspawn", "--version"], stdout=subprocess.PIPE).stdout.strip().split()[1])
-
-
-def run_workspace_command(
-    state: MkosiState,
-    cmd: Sequence[PathString],
-    network: bool = False,
-    env: Optional[Mapping[str, str]] = None,
-    nspawn_params: Optional[list[str]] = None,
-    capture_stdout: bool = False,
-    check: bool = True,
-) -> CompletedProcess:
-    nspawn = [
-        "systemd-nspawn",
-        "--quiet",
-        f"--directory={state.root}",
-        "--machine=mkosi-" + uuid.uuid4().hex,
-        "--as-pid2",
-        "--link-journal=no",
-        "--register=no",
-        f"--bind={state.var_tmp()}:/var/tmp",
-        "--setenv=SYSTEMD_OFFLINE=1",
-        *nspawn_rlimit_params(),
-    ]
-    stdout = None
-
-    if network:
-        # If we're using the host network namespace, use the same resolver
-        nspawn += ["--bind-ro=/etc/resolv.conf"]
-    else:
-        nspawn += ["--private-network"]
-
-    if env:
-        nspawn += [f"--setenv={k}={v}" for k, v in env.items()]
-    if "workspace-command" in ARG_DEBUG:
-        nspawn += ["--setenv=SYSTEMD_LOG_LEVEL=debug"]
-
-    if nspawn_params:
-        nspawn += nspawn_params
-
-    if capture_stdout:
-        stdout = subprocess.PIPE
-        nspawn += ["--console=pipe"]
-
-    if state.config.nspawn_keep_unit:
-        nspawn += ["--keep-unit"]
-
-    try:
-        return run([*nspawn, "--", *cmd], check=check, stdout=stdout, text=capture_stdout)
-    except subprocess.CalledProcessError as e:
-        if "workspace-command" in ARG_DEBUG:
-            run(nspawn, check=False)
-        die(f"Workspace command {shell_join(cmd)} returned non-zero exit code {e.returncode}.")
-
-
-@contextlib.contextmanager
-def do_delay_interrupt() -> Iterator[None]:
-    # CTRL+C is sent to the entire process group. We delay its handling in mkosi itself so the subprocess can
-    # exit cleanly before doing mkosi's cleanup. If we don't do this, we get device or resource is busy
-    # errors when unmounting stuff later on during cleanup. We only delay a single CTRL+C interrupt so that a
-    # user can always exit mkosi even if a subprocess hangs by pressing CTRL+C twice.
-    interrupted = False
-
-    def handler(signal: int, frame: Optional[FrameType]) -> None:
-        nonlocal interrupted
-        if interrupted:
-            raise KeyboardInterrupt()
-        else:
-            interrupted = True
-
-    s = signal.signal(signal.SIGINT, handler)
-
-    try:
-        yield
-    finally:
-        signal.signal(signal.SIGINT, s)
-
-        if interrupted:
-            die("Interrupted")
-
-
-@contextlib.contextmanager
-def do_noop() -> Iterator[None]:
-    yield
-
-
-# Borrowed from https://github.com/python/typeshed/blob/3d14016085aed8bcf0cf67e9e5a70790ce1ad8ea/stdlib/3/subprocess.pyi#L24
-_FILE = Union[None, int, IO[Any]]
-
-
-def spawn(
-    cmdline: Sequence[PathString],
-    delay_interrupt: bool = True,
-    stdout: _FILE = None,
-    stderr: _FILE = None,
-    **kwargs: Any,
-) -> Popen:
-    if "run" in ARG_DEBUG:
-        MkosiPrinter.info(f"+ {shell_join(cmdline)}")
-
-    if not stdout and not stderr:
-        # Unless explicit redirection is done, print all subprocess
-        # output on stderr, since we do so as well for mkosi's own
-        # output.
-        stdout = sys.stderr
-
-    cm = do_delay_interrupt if delay_interrupt else do_noop
-    try:
-        with cm():
-            return subprocess.Popen(cmdline, stdout=stdout, stderr=stderr, **kwargs)
-    except FileNotFoundError:
-        die(f"{cmdline[0]} not found in PATH.")
-
-
-def run(
-    cmdline: Sequence[PathString],
-    check: bool = True,
-    delay_interrupt: bool = True,
-    stdout: _FILE = None,
-    stderr: _FILE = None,
-    env: Mapping[str, Any] = {},
-    **kwargs: Any,
-) -> CompletedProcess:
-    cmdline = [os.fspath(x) for x in cmdline]
-
-    if "run" in ARG_DEBUG:
-        MkosiPrinter.info(f"+ {shell_join(cmdline)}")
-
-    if not stdout and not stderr:
-        # Unless explicit redirection is done, print all subprocess
-        # output on stderr, since we do so as well for mkosi's own
-        # output.
-        stdout = sys.stderr
-
-    cm = do_delay_interrupt if delay_interrupt else do_noop
-    try:
-        with cm():
-            return subprocess.run(cmdline, check=check, stdout=stdout, stderr=stderr, env={**os.environ, **env}, **kwargs)
-    except FileNotFoundError:
-        die(f"{cmdline[0]} not found in PATH.")
-
-
 def tmp_dir() -> Path:
     path = os.environ.get("TMPDIR") or "/var/tmp"
     return Path(path)
@@ -715,105 +486,6 @@ def path_relative_to_cwd(path: Path) -> Path:
         return path
 
 
-def die(message: str, exception: type[MkosiException] = MkosiException) -> NoReturn:
-    MkosiPrinter.warn(f"Error: {message}")
-    raise exception(message)
-
-
-def warn(message: str) -> None:
-    MkosiPrinter.warn(f"Warning: {message}")
-
-
-class MkosiPrinter:
-    out_file = sys.stderr
-    isatty = out_file.isatty()
-
-    bold = "\033[0;1;39m" if isatty else ""
-    red = "\033[31;1m" if isatty else ""
-    reset = "\033[0m" if isatty else ""
-
-    prefix = "‣ "
-
-    level = 0
-
-    @classmethod
-    def _print(cls, text: str) -> None:
-        cls.out_file.write(text)
-
-    @classmethod
-    def color_error(cls, text: Any) -> str:
-        return f"{cls.red}{text}{cls.reset}"
-
-    @classmethod
-    def print_step(cls, text: str) -> None:
-        prefix = cls.prefix + " " * cls.level
-        if sys.exc_info()[0]:
-            # We are falling through exception handling blocks.
-            # De-emphasize this step here, so the user can tell more
-            # easily which step generated the exception. The exception
-            # or error will only be printed after we finish cleanup.
-            cls._print(f"{prefix}({text})\n")
-        else:
-            cls._print(f"{prefix}{cls.bold}{text}{cls.reset}\n")
-
-    @classmethod
-    def info(cls, text: str) -> None:
-        cls._print(text + "\n")
-
-    @classmethod
-    def warn(cls, text: str) -> None:
-        cls._print(f"{cls.prefix}{cls.color_error(text)}\n")
-
-    @classmethod
-    @contextlib.contextmanager
-    def complete_step(cls, text: str, text2: Optional[str] = None) -> Iterator[list[Any]]:
-        cls.print_step(text)
-
-        cls.level += 1
-        try:
-            args: list[Any] = []
-            yield args
-        finally:
-            cls.level -= 1
-            assert cls.level >= 0
-
-        if text2 is not None:
-            cls.print_step(text2.format(*args))
-
-
-def chown_to_running_user(path: Path) -> None:
-    uid = int(os.getenv("SUDO_UID") or os.getenv("PKEXEC_UID") or str(os.getuid()))
-    user = pwd.getpwuid(uid).pw_name
-    gid = pwd.getpwuid(uid).pw_gid
-
-    with MkosiPrinter.complete_step(
-        f"Changing ownership of output file {path} to user {user}…",
-        f"Changed ownership of {path}",
-    ):
-        os.chown(path, uid, gid)
-
-
-def mkdirp_chown_current_user(
-    path: PathString,
-    *,
-    chown: bool = True,
-    mode: int = 0o777,
-    exist_ok: bool = True
-) -> None:
-    abspath = Path(path).absolute()
-    path = Path()
-
-    for d in abspath.parts:
-        path /= d
-        if path.exists():
-            continue
-
-        path.mkdir(mode=mode, exist_ok=exist_ok)
-
-        if chown:
-            chown_to_running_user(path)
-
-
 def safe_tar_extract(tar: tarfile.TarFile, path: Path=Path("."), *, numeric_owner: bool=False) -> None:
     """Extract a tar without CVE-2007-4559.
 
@@ -837,9 +509,6 @@ def safe_tar_extract(tar: tarfile.TarFile, path: Path=Path("."), *, numeric_owne
     tar.extractall(path, numeric_owner=numeric_owner)
 
 
-complete_step = MkosiPrinter.complete_step
-
-
 def disable_pam_securetty(root: Path) -> None:
     def _rm_securetty(line: str) -> str:
         if "pam_securetty.so" in line:
@@ -874,17 +543,12 @@ def sort_packages(packages: Iterable[str]) -> list[str]:
     return sorted(packages, key=sort)
 
 
-def scandir_recursive(
-    root: Path,
-    filter: Optional[Callable[[os.DirEntry[str]], T]] = None,
-) -> Iterator[T]:
-    """Recursively walk the tree starting at @root, optionally apply filter, yield non-none values"""
-    queue: Deque[Union[str, Path]] = collections.deque([root])
-
-    while queue:
-        for entry in os.scandir(queue.pop()):
-            pred = filter(entry) if filter is not None else entry
-            if pred is not None:
-                yield cast(T, pred)
-            if entry.is_dir(follow_symlinks=False):
-                queue.append(entry.path)
+def flatten(lists: Iterable[Iterable[T]]) -> list[T]:
+    """Flatten a sequence of sequences into a single list."""
+    return list(itertools.chain.from_iterable(lists))
+
+
+def current_user_uid_gid() -> tuple[int, int]:
+    uid = int(os.getenv("SUDO_UID") or os.getenv("PKEXEC_UID") or os.getuid())
+    gid = pwd.getpwuid(uid).pw_gid
+    return uid, gid
index 866f9d2f5525cda619c7149716123040407713c4..0c6491d3c3cf9c5d88a1414e98a432ed1f6ce1a5 100644 (file)
@@ -3,17 +3,11 @@
 import os
 from textwrap import dedent
 
-from mkosi.backend import (
-    MkosiPrinter,
-    MkosiState,
-    add_packages,
-    complete_step,
-    disable_pam_securetty,
-    run,
-    sort_packages,
-)
+from mkosi.backend import MkosiState, add_packages, disable_pam_securetty, sort_packages
 from mkosi.distributions import DistributionInstaller
-from mkosi.mounts import mount_api_vfs
+from mkosi.log import complete_step
+from mkosi.run import run_with_apivfs
+from mkosi.types import PathString
 
 
 class ArchInstaller(DistributionInstaller):
@@ -32,9 +26,6 @@ class ArchInstaller(DistributionInstaller):
 
 @complete_step("Installing Arch Linux…")
 def install_arch(state: MkosiState) -> None:
-    if state.config.release is not None:
-        MkosiPrinter.info("Distribution release specification is not supported for Arch Linux, ignoring.")
-
     assert state.config.mirror
 
     if state.config.local_mirror:
@@ -47,26 +38,6 @@ def install_arch(state: MkosiState) -> None:
 
     # Create base layout for pacman and pacman-key
     os.makedirs(state.root / "var/lib/pacman", 0o755, exist_ok=True)
-    os.makedirs(state.root / "etc/pacman.d/gnupg", 0o755, exist_ok=True)
-
-    # Permissions on these directories are all 0o777 because of 'mount --bind'
-    # limitations but pacman expects them to be 0o755 so we fix them before
-    # calling pacman (except /var/tmp which is 0o1777).
-    fix_permissions_dirs = {
-        "boot": 0o755,
-        "etc": 0o755,
-        "etc/pacman.d": 0o755,
-        "var": 0o755,
-        "var/lib": 0o755,
-        "var/cache": 0o755,
-        "var/cache/pacman": 0o755,
-        "var/tmp": 0o1777,
-        "run": 0o755,
-    }
-
-    for dir, permissions in fix_permissions_dirs.items():
-        if (path := state.root / dir).exists():
-            path.chmod(permissions)
 
     pacman_conf = state.workspace / "pacman.conf"
     if state.config.repository_key_check:
@@ -82,7 +53,7 @@ def install_arch(state: MkosiState) -> None:
                 [options]
                 RootDir = {state.root}
                 LogFile = /dev/null
-                CacheDir = {state.root}/var/cache/pacman/pkg/
+                CacheDir = {state.config.cache_path}
                 GPGDir = /etc/pacman.d/gnupg/
                 HookDir = {state.root}/etc/pacman.d/hooks/
                 HoldPkg = pacman glibc
@@ -141,9 +112,14 @@ def install_arch(state: MkosiState) -> None:
     if not state.do_run_build_script and state.config.ssh:
         add_packages(state.config, packages, "openssh")
 
-    with mount_api_vfs(state.root):
-        run(["pacman", "--config", pacman_conf, "--noconfirm", "-Sy", *sort_packages(packages)],
-            env={"KERNEL_INSTALL_BYPASS": state.environment.get("KERNEL_INSTALL_BYPASS", "1")})
+    cmdline: list[PathString] = [
+        "pacman",
+        "--config",  pacman_conf,
+        "--noconfirm",
+        "-Sy", *sort_packages(packages),
+    ]
+
+    run_with_apivfs(state, cmdline, env=dict(KERNEL_INSTALL_BYPASS="1"))
 
     state.root.joinpath("etc/pacman.d/mirrorlist").write_text(f"Server = {state.config.mirror}/$repo/os/$arch\n")
 
index 5bf5053da2efe1c47573a30e01c913a857084382..35d62f89f894ac207a6f925ad0cfb4116d0c16bd 100644 (file)
@@ -3,18 +3,12 @@
 import shutil
 from pathlib import Path
 
-from mkosi.backend import (
-    Distribution,
-    MkosiConfig,
-    MkosiState,
-    add_packages,
-    complete_step,
-    die,
-    run_workspace_command,
-)
+from mkosi.backend import Distribution, MkosiConfig, MkosiState, add_packages
 from mkosi.distributions import DistributionInstaller
 from mkosi.distributions.fedora import Repo, install_packages_dnf, invoke_dnf, setup_dnf
+from mkosi.log import complete_step, die
 from mkosi.remove import unlink_try_hard
+from mkosi.run import run_workspace_command
 
 
 def move_rpm_db(root: Path) -> None:
@@ -58,7 +52,9 @@ class CentosInstaller(DistributionInstaller):
         setup_dnf(state, repos)
 
         if state.config.distribution == Distribution.centos:
-            state.workspace.joinpath("vars/stream").write_text(f"{state.config.release}-stream")
+            env = dict(DNF_VAR_stream=f"{state.config.release}-stream")
+        else:
+            env = {}
 
         packages = {*state.config.packages}
         add_packages(state.config, packages, "systemd", "dnf")
@@ -82,7 +78,7 @@ class CentosInstaller(DistributionInstaller):
         if release <= 8:
             add_packages(state.config, packages, "glibc-minimal-langpack")
 
-        install_packages_dnf(state, packages)
+        install_packages_dnf(state, packages, env)
 
         # On Fedora, the default rpmdb has moved to /usr/lib/sysimage/rpm so if that's the case we need to
         # move it back to /var/lib/rpm on CentOS.
index bcea213b5ba6eccf4a0bdde325a7c3da7b9d2bb1..dba18f87050ace73faa6ef7a1d4b641e6686e315 100644 (file)
@@ -1,30 +1,17 @@
 # SPDX-License-Identifier: LGPL-2.1+
 
-import contextlib
 import os
+import shutil
 import subprocess
-from collections.abc import Iterable, Iterator
+from collections.abc import Iterable
 from pathlib import Path
 from textwrap import dedent
-from typing import TYPE_CHECKING, Any
-
-from mkosi.backend import (
-    MkosiState,
-    PathString,
-    add_packages,
-    complete_step,
-    disable_pam_securetty,
-    run,
-    run_workspace_command,
-)
+
+from mkosi.backend import MkosiState, add_packages, disable_pam_securetty
 from mkosi.distributions import DistributionInstaller
 from mkosi.install import install_skeleton_trees, write_resource
-from mkosi.mounts import mount_api_vfs, mount_bind
-
-if TYPE_CHECKING:
-    CompletedProcess = subprocess.CompletedProcess[Any]
-else:
-    CompletedProcess = subprocess.CompletedProcess
+from mkosi.run import run, run_with_apivfs
+from mkosi.types import _FILE, CompletedProcess, PathString
 
 
 class DebianInstaller(DistributionInstaller):
@@ -45,7 +32,6 @@ class DebianInstaller(DistributionInstaller):
             # the base image.
             state.root.joinpath("etc/resolv.conf").unlink(missing_ok=True)
             state.root.joinpath("etc/resolv.conf").symlink_to("../run/systemd/resolve/resolv.conf")
-            run(["systemctl", "--root", state.root, "enable", "systemd-resolved"])
 
     @classmethod
     def cache_path(cls) -> list[str]:
@@ -79,6 +65,7 @@ class DebianInstaller(DistributionInstaller):
                 "--variant=minbase",
                 "--include=ca-certificates",
                 "--merged-usr",
+                f"--cache-dir={state.cache}",
                 f"--components={','.join(repos)}",
             ]
 
@@ -95,7 +82,9 @@ class DebianInstaller(DistributionInstaller):
             mirror = state.config.local_mirror or state.config.mirror
             assert mirror is not None
             cmdline += [state.config.release, state.root, mirror]
-            run(cmdline)
+
+            # Pretend we're lxc so debootstrap skips its mknod check.
+            run_with_apivfs(state, cmdline, env=dict(container="lxc"))
 
         # Install extra packages via the secondary APT run, because it is smarter and can deal better with any
         # conflicts. dbus and libpam-systemd are optional dependencies for systemd in debian so we include them
@@ -124,18 +113,21 @@ class DebianInstaller(DistributionInstaller):
         policyrcd.chmod(0o755)
 
         doc_paths = [
-            "/usr/share/locale",
-            "/usr/share/doc",
-            "/usr/share/man",
-            "/usr/share/groff",
-            "/usr/share/info",
-            "/usr/share/lintian",
-            "/usr/share/linda",
+            state.root / "usr/share/locale",
+            state.root / "usr/share/doc",
+            state.root / "usr/share/man",
+            state.root / "usr/share/groff",
+            state.root / "usr/share/info",
+            state.root / "usr/share/lintian",
+            state.root / "usr/share/linda",
         ]
         if not state.config.with_docs:
             # Remove documentation installed by debootstrap
-            cmdline = ["/bin/rm", "-rf", *doc_paths]
-            run_workspace_command(state, cmdline)
+            for d in doc_paths:
+                try:
+                    shutil.rmtree(d)
+                except FileNotFoundError:
+                    pass
             # Create dpkg.cfg to ignore documentation on new packages
             dpkg_nodoc_conf = state.root / "etc/dpkg/dpkg.cfg.d/01_nodoc"
             with dpkg_nodoc_conf.open("w") as f:
@@ -258,59 +250,52 @@ def debootstrap_knows_arg(arg: str) -> bool:
                                                        stdout=subprocess.PIPE, check=False).stdout
 
 
-@contextlib.contextmanager
-def mount_apt_local_mirror(state: MkosiState) -> Iterator[None]:
-    # Ensure apt inside the image can see the local mirror outside of it
-    mirror = state.config.local_mirror or state.config.mirror
-    if not mirror or not mirror.startswith("file:"):
-        yield
-        return
-
-    # Strip leading '/' as Path() does not behave well when concatenating
-    mirror_dir = mirror[5:].lstrip("/")
-
-    with complete_step("Mounting apt local mirror…", "Unmounting apt local mirror…"):
-        with mount_bind(Path("/") / mirror_dir, state.root / mirror_dir):
-            yield
-
-
 def invoke_apt(
     state: MkosiState,
     subcommand: str,
     operation: str,
     extra: Iterable[str],
-    **kwargs: Any,
+    stdout: _FILE = None,
 ) -> CompletedProcess:
 
-    config_file = state.workspace / "apt.conf"
+    state.workspace.joinpath("apt").mkdir(exist_ok=True)
+    state.workspace.joinpath("apt/log").mkdir(exist_ok=True)
+    state.root.joinpath("var/lib/dpkg").mkdir(exist_ok=True)
+    state.root.joinpath("var/lib/dpkg/status").touch()
+
+    config_file = state.workspace / "apt/apt.conf"
     debarch = DEBIAN_ARCHITECTURES[state.config.architecture]
 
-    if not config_file.exists():
-        config_file.write_text(
-            dedent(
-                f"""\
-                Dir "{state.root}";
-                DPkg::Chroot-Directory "{state.root}";
-                """
-            )
+    config_file.write_text(
+        dedent(
+            f"""\
+            APT::Architecture "{debarch}";
+            APT::Immediate-Configure "off";
+            Dir::Cache "{state.cache}";
+            Dir::State "{state.workspace / "apt"}";
+            Dir::State::status "{state.root / "var/lib/dpkg/status"}";
+            Dir::Etc "{state.root / "etc/apt"}";
+            Dir::Log "{state.workspace / "apt/log"}";
+            DPkg::Options:: "--root={state.root}";
+            DPkg::Options:: "--log={state.workspace / "apt/dpkg.log"}";
+            DPkg::Install::Recursive::Minimum "1000";
+            """
         )
+    )
 
     cmdline = [
         f"/usr/bin/apt-{subcommand}",
-        "-o", f"APT::Architecture={debarch}",
-        "-o", "dpkg::install::recursive::minimum=1000",
         operation,
         *extra,
     ]
     env = dict(
-        APT_CONFIG=f"{config_file}",
+        APT_CONFIG=config_file,
         DEBIAN_FRONTEND="noninteractive",
-        DEBCONF_NONINTERACTIVE_SEEN="true",
+        DEBCONF_INTERACTIVE_SEEN="true",
         INITRD="No",
     )
 
-    with mount_apt_local_mirror(state), mount_api_vfs(state.root):
-        return run(cmdline, env=env, text=True, **kwargs)
+    return run_with_apivfs(state, cmdline, stdout=stdout, env=env)
 
 
 def add_apt_package_if_exists(state: MkosiState, extra_packages: set[str], package: str) -> None:
index 62acb476d611586f55e5c8663d10439d08c680fe..400b2e7aa3be7a909ac9311eadd776a754a9a559 100644 (file)
@@ -3,25 +3,22 @@
 import shutil
 import urllib.parse
 import urllib.request
-from collections.abc import Iterable, Sequence
+from collections.abc import Iterable, Mapping, Sequence
 from pathlib import Path
 from textwrap import dedent
-from typing import NamedTuple, Optional
+from typing import Any, NamedTuple, Optional
 
 from mkosi.backend import (
     Distribution,
-    MkosiPrinter,
     MkosiState,
     add_packages,
-    complete_step,
     detect_distribution,
-    run,
     sort_packages,
-    warn,
 )
 from mkosi.distributions import DistributionInstaller
-from mkosi.mounts import mount_api_vfs
+from mkosi.log import MkosiPrinter, complete_step, warn
 from mkosi.remove import unlink_try_hard
+from mkosi.run import run_with_apivfs
 
 FEDORA_KEYS_MAP = {
     "36": "53DED2CB922D8B8D9E63FD18999F7CBF38AB71F4",
@@ -134,9 +131,9 @@ def make_rpm_list(state: MkosiState, packages: set[str]) -> set[str]:
     return packages
 
 
-def install_packages_dnf(state: MkosiState, packages: set[str],) -> None:
+def install_packages_dnf(state: MkosiState, packages: set[str], env: Mapping[str, Any] = {}) -> None:
     packages = make_rpm_list(state, packages)
-    invoke_dnf(state, 'install', packages)
+    invoke_dnf(state, 'install', packages, env)
 
 
 class Repo(NamedTuple):
@@ -148,10 +145,9 @@ class Repo(NamedTuple):
 
 
 def setup_dnf(state: MkosiState, repos: Sequence[Repo] = ()) -> None:
-    gpgcheck = True
+    with state.workspace.joinpath("dnf.conf").open("w") as f:
+        gpgcheck = True
 
-    repo_file = state.workspace / "mkosi.repo"
-    with repo_file.open("w") as f:
         for repo in repos:
             gpgkey: Optional[str] = None
 
@@ -170,50 +166,35 @@ def setup_dnf(state: MkosiState, repos: Sequence[Repo] = ()) -> None:
                     name={repo.id}
                     {repo.url}
                     gpgkey={gpgkey or ''}
+                    gpgcheck={int(gpgcheck)}
                     enabled={int(repo.enabled)}
-                    check_config_file_age=False
+                    check_config_file_age=0
                     """
                 )
             )
 
-    default_repos  = f"reposdir={state.workspace} {' '.join(str(p) for p in state.config.repo_dirs)}"
 
-    vars_dir = state.workspace / "vars"
-    vars_dir.mkdir(exist_ok=True)
-
-    config_file = state.workspace / "dnf.conf"
-    config_file.write_text(
-        dedent(
-            f"""\
-            [main]
-            gpgcheck={'1' if gpgcheck else '0'}
-            {default_repos }
-            varsdir={vars_dir}
-            """
-        )
-    )
-
-
-def invoke_dnf(state: MkosiState, command: str, packages: Iterable[str]) -> None:
+def invoke_dnf(state: MkosiState, command: str, packages: Iterable[str], env: Mapping[str, Any] = {}) -> None:
     if state.config.distribution == Distribution.fedora:
         release, _ = parse_fedora_release(state.config.release)
     else:
-        release = state.config.release.strip("-stream")
-
-    config_file = state.workspace / "dnf.conf"
+        release = state.config.release
 
-    cmd = 'dnf' if shutil.which('dnf') else 'yum'
+    state.workspace.joinpath("vars").mkdir(exist_ok=True)
 
     cmdline = [
-        cmd,
+        'dnf' if shutil.which('dnf') else 'yum',
         "-y",
-        f"--config={config_file}",
+        f"--config={state.workspace.joinpath('dnf.conf')}",
         "--best",
         "--allowerasing",
         f"--releasever={release}",
         f"--installroot={state.root}",
         "--setopt=keepcache=1",
         "--setopt=install_weak_deps=0",
+        f"--setopt=cachedir={state.cache}",
+        f"--setopt=reposdir={' '.join(str(p) for p in state.config.repo_dirs)}",
+        f"--setopt=varsdir={state.workspace / 'vars'}",
         "--noplugins",
     ]
 
@@ -235,8 +216,7 @@ def invoke_dnf(state: MkosiState, command: str, packages: Iterable[str]) -> None
 
     cmdline += [command, *sort_packages(packages)]
 
-    with mount_api_vfs(state.root):
-        run(cmdline, env={"KERNEL_INSTALL_BYPASS": state.environment.get("KERNEL_INSTALL_BYPASS", "1")})
+    run_with_apivfs(state, cmdline, env=dict(KERNEL_INSTALL_BYPASS="1") | env)
 
     distribution, _ = detect_distribution()
     if distribution not in (Distribution.debian, Distribution.ubuntu):
index 487bdf44cb066a31eae4e949ee56154d4f30a08c..4a2b95132ba4370edd4615c0d5f8c0ae8efb1222 100644 (file)
@@ -9,19 +9,12 @@ from collections.abc import Sequence
 from pathlib import Path
 from textwrap import dedent
 
-from mkosi.backend import (
-    ARG_DEBUG,
-    MkosiException,
-    MkosiPrinter,
-    MkosiState,
-    complete_step,
-    die,
-    run_workspace_command,
-    safe_tar_extract,
-)
+from mkosi.backend import MkosiState, safe_tar_extract
 from mkosi.distributions import DistributionInstaller
 from mkosi.install import copy_path, flock
+from mkosi.log import ARG_DEBUG, MkosiException, MkosiPrinter, complete_step, die
 from mkosi.remove import unlink_try_hard
+from mkosi.run import run_workspace_command
 
 ARCHITECTURES = {
     "x86_64": ("amd64", "arch/x86/boot/bzImage"),
@@ -161,13 +154,6 @@ class Gentoo:
 
         self.portage_cfg_dir.mkdir(parents=True, exist_ok=True)
 
-        self.DEFAULT_NSPAWN_PARAMS = [
-            "--capability=CAP_SYS_ADMIN,CAP_MKNOD",
-            f"--bind={self.portage_cfg['PORTDIR']}",
-            f"--bind={self.portage_cfg['DISTDIR']}",
-            f"--bind={self.portage_cfg['PKGDIR']}",
-        ]
-
         jobs = os.cpu_count() or 1
         self.emerge_default_opts = [
             "--buildpkg=y",
@@ -341,11 +327,11 @@ class Gentoo:
         if self.state.do_run_build_script:
             self.invoke_emerge(pkgs=self.state.config.build_packages)
         if self.state.config.packages:
-            self.invoke_emerge(pkgs=self.state.config.packages, check=False)
+            self.invoke_emerge(pkgs=self.state.config.packages)
+
 
     def invoke_emerge(
         self,
-        check: bool = True,
         inside_stage3: bool = True,
         pkgs: Sequence[str] = (),
         actions: Sequence[str] = (),
@@ -370,16 +356,23 @@ class Gentoo:
 
             MkosiPrinter.print_step("Invoking emerge(1) inside stage3"
                                     f"{self.root}")
-            run_workspace_command(self.state, cmd, network=True, env=self.emerge_vars,
-                                  nspawn_params=self.DEFAULT_NSPAWN_PARAMS,
-                                  check=check)
+
+            bwrap = [
+                "--bind", self.portage_cfg['PORTDIR'], self.portage_cfg['PORTDIR'],
+                "--bind", self.portage_cfg['DISTDIR'], self.portage_cfg['DISTDIR'],
+                "--bind", self.portage_cfg['PKGDIR'],  self.portage_cfg['PKGDIR'],
+            ]
+            run_workspace_command(self.state, cmd, network=True, bwrap_params=bwrap)
 
     def _dbg(self, state: MkosiState) -> None:
         """this is for dropping into shell to see what's wrong"""
 
-        cmd = ["/usr/bin/sh"]
-        run_workspace_command(self.state, cmd, network=True,
-                              nspawn_params=self.DEFAULT_NSPAWN_PARAMS)
+        bwrap = [
+            "--bind", self.portage_cfg['PORTDIR'], self.portage_cfg['PORTDIR'],
+            "--bind", self.portage_cfg['DISTDIR'], self.portage_cfg['DISTDIR'],
+            "--bind", self.portage_cfg['PKGDIR'],  self.portage_cfg['PKGDIR'],
+        ]
+        run_workspace_command(self.state, ["sh"], network=True, bwrap_params=bwrap)
 
 
 class GentooInstaller(DistributionInstaller):
index 0449e067ef59f775dc0b73f4530e909992d18e7d..d01783ef04823b5575253d53d4a3bbd6acc23993 100644 (file)
@@ -2,9 +2,10 @@
 
 from pathlib import Path
 
-from mkosi.backend import MkosiState, add_packages, complete_step, disable_pam_securetty
+from mkosi.backend import MkosiState, add_packages, disable_pam_securetty
 from mkosi.distributions import DistributionInstaller
 from mkosi.distributions.fedora import Repo, install_packages_dnf, invoke_dnf, setup_dnf
+from mkosi.log import complete_step
 
 
 class MageiaInstaller(DistributionInstaller):
index cfb84370c118fbfac60031ff9b7732b28ecbf8fc..76339069b59bf02c75b29a23a5527832d8aba0a8 100644 (file)
@@ -2,9 +2,10 @@
 
 from pathlib import Path
 
-from mkosi.backend import MkosiState, add_packages, complete_step
+from mkosi.backend import MkosiState, add_packages
 from mkosi.distributions import DistributionInstaller
 from mkosi.distributions.fedora import Repo, install_packages_dnf, invoke_dnf, setup_dnf
+from mkosi.log import complete_step
 
 
 class OpenmandrivaInstaller(DistributionInstaller):
index 65f3620aaacad68b3b415e1b9969876ac173951b..f4540376dd11c84789c2979b0084c0e108b2ec66 100644 (file)
@@ -2,17 +2,11 @@
 
 import shutil
 
-from mkosi.backend import (
-    MkosiState,
-    PathString,
-    add_packages,
-    complete_step,
-    patch_file,
-    run,
-    sort_packages,
-)
+from mkosi.backend import MkosiState, add_packages, patch_file, sort_packages
 from mkosi.distributions import DistributionInstaller
-from mkosi.mounts import mount_api_vfs
+from mkosi.log import complete_step
+from mkosi.run import run, run_with_apivfs
+from mkosi.types import PathString
 
 
 class OpensuseInstaller(DistributionInstaller):
@@ -92,6 +86,7 @@ def install_opensuse(state: MkosiState) -> None:
         "--root",
         state.root,
         "--gpg-auto-import-keys" if state.config.repository_key_check else "--no-gpg-checks",
+        "--cache-dir", state.cache,
         "install",
         "-y",
         "--no-recommends",
@@ -99,8 +94,7 @@ def install_opensuse(state: MkosiState) -> None:
         *sort_packages(packages),
     ]
 
-    with mount_api_vfs(state.root):
-        run(cmdline)
+    run_with_apivfs(state, cmdline)
 
     # Disable package caching in the image that was enabled previously to populate the package cache.
     run(["zypper", "--root", state.root, "modifyrepo", "-K", "repo-oss"])
index ed0977891e7790f4e21a99b9932fe130317e9eb5..62feb6b0146ba2bcd907d32db1da1fba057258e0 100644 (file)
@@ -11,7 +11,9 @@ from pathlib import Path
 from textwrap import dedent
 from typing import Optional
 
-from mkosi.backend import MkosiState, complete_step, run
+from mkosi.backend import MkosiState
+from mkosi.log import complete_step
+from mkosi.run import run
 
 
 def make_executable(path: Path) -> None:
@@ -57,8 +59,16 @@ def flock(path: Path) -> Iterator[Path]:
         os.close(fd)
 
 
-def copy_path(src: Path, dst: Path, parents: bool = False) -> None:
-    run(["cp", "--archive", "--no-target-directory", "--reflink=auto", src, dst])
+def copy_path(src: Path, dst: Path, preserve_owner: bool = True) -> None:
+    run([
+        "cp",
+        "--recursive",
+        "--no-dereference",
+        f"--preserve=mode,timestamps,links,xattr{',ownership' if preserve_owner else ''}",
+        "--no-target-directory",
+        "--reflink=auto",
+        src, dst,
+    ])
 
 
 def install_skeleton_trees(state: MkosiState, cached: bool, *, late: bool=False) -> None:
@@ -74,7 +84,7 @@ def install_skeleton_trees(state: MkosiState, cached: bool, *, late: bool=False)
     with complete_step("Copying in skeleton file trees…"):
         for tree in state.config.skeleton_trees:
             if tree.is_dir():
-                copy_path(tree, state.root)
+                copy_path(tree, state.root, preserve_owner=False)
             else:
                 # unpack_archive() groks Paths, but mypy doesn't know this.
                 # Pretend that tree is a str.
diff --git a/mkosi/log.py b/mkosi/log.py
new file mode 100644 (file)
index 0000000..bfecf46
--- /dev/null
@@ -0,0 +1,83 @@
+import contextlib
+import sys
+from typing import Any, Iterator, NoReturn, Optional
+
+# This global should be initialized after parsing arguments
+ARG_DEBUG: set[str] = set()
+
+
+class MkosiException(Exception):
+    """Leads to sys.exit"""
+
+
+class MkosiNotSupportedException(MkosiException):
+    """Leads to sys.exit when an invalid combination of parsed arguments happens"""
+
+
+def die(message: str, exception: type[MkosiException] = MkosiException) -> NoReturn:
+    MkosiPrinter.warn(f"Error: {message}")
+    raise exception(message)
+
+
+def warn(message: str) -> None:
+    MkosiPrinter.warn(f"Warning: {message}")
+
+
+class MkosiPrinter:
+    out_file = sys.stderr
+    isatty = out_file.isatty()
+
+    bold = "\033[0;1;39m" if isatty else ""
+    red = "\033[31;1m" if isatty else ""
+    reset = "\033[0m" if isatty else ""
+
+    prefix = "‣ "
+
+    level = 0
+
+    @classmethod
+    def _print(cls, text: str) -> None:
+        cls.out_file.write(text)
+
+    @classmethod
+    def color_error(cls, text: Any) -> str:
+        return f"{cls.red}{text}{cls.reset}"
+
+    @classmethod
+    def print_step(cls, text: str) -> None:
+        prefix = cls.prefix + " " * cls.level
+        if sys.exc_info()[0]:
+            # We are falling through exception handling blocks.
+            # De-emphasize this step here, so the user can tell more
+            # easily which step generated the exception. The exception
+            # or error will only be printed after we finish cleanup.
+            cls._print(f"{prefix}({text})\n")
+        else:
+            cls._print(f"{prefix}{cls.bold}{text}{cls.reset}\n")
+
+    @classmethod
+    def info(cls, text: str) -> None:
+        cls._print(text + "\n")
+
+    @classmethod
+    def warn(cls, text: str) -> None:
+        cls._print(f"{cls.prefix}{cls.color_error(text)}\n")
+
+    @classmethod
+    @contextlib.contextmanager
+    def complete_step(cls, text: str, text2: Optional[str] = None) -> Iterator[list[Any]]:
+        cls.print_step(text)
+
+        cls.level += 1
+        try:
+            args: list[Any] = []
+            yield args
+        finally:
+            cls.level -= 1
+            assert cls.level >= 0
+
+        if text2 is not None:
+            cls.print_step(text2.format(*args))
+
+
+complete_step = MkosiPrinter.complete_step
index ff867d3d6da245c8ec7a0b8fe5ee90c8e622fc17..1d0c1e2f80f8c74ff2485a522c03cac1fd5013c7 100644 (file)
@@ -8,7 +8,8 @@ from subprocess import DEVNULL, PIPE
 from textwrap import dedent
 from typing import IO, Any, Optional
 
-from mkosi.backend import Distribution, ManifestFormat, MkosiConfig, PackageType, run
+from mkosi.backend import Distribution, ManifestFormat, MkosiConfig, PackageType
+from mkosi.run import run
 
 
 @dataclasses.dataclass
index a2d5cc309e8d287153855b5ae33f75c10bad7ba8..9bb624b3b0b27d841ed45012d242f8078d916c18 100644 (file)
@@ -1,15 +1,34 @@
 # SPDX-License-Identifier: LGPL-2.1+
 
+import collections
 import contextlib
 import os
 import stat
 from collections.abc import Iterator, Sequence
 from pathlib import Path
-from typing import ContextManager, Optional, Union, cast
+from typing import Callable, ContextManager, Deque, Optional, TypeVar, Union, cast
 
-from mkosi.backend import complete_step, run, scandir_recursive
+from mkosi.log import complete_step
+from mkosi.run import run
+from mkosi.types import PathString
 
-PathString = Union[Path, str]
+T = TypeVar("T")
+
+
+def scandir_recursive(
+    root: Path,
+    filter: Optional[Callable[[os.DirEntry[str]], T]] = None,
+) -> Iterator[T]:
+    """Recursively walk the tree starting at @root, optionally apply filter, yield non-none values"""
+    queue: Deque[Union[str, Path]] = collections.deque([root])
+
+    while queue:
+        for entry in os.scandir(queue.pop()):
+            pred = filter(entry) if filter is not None else entry
+            if pred is not None:
+                yield cast(T, pred)
+            if entry.is_dir(follow_symlinks=False):
+                queue.append(entry.path)
 
 
 def stat_is_whiteout(st: os.stat_result) -> bool:
@@ -31,12 +50,12 @@ def delete_whiteout_files(path: Path) -> None:
 
 @contextlib.contextmanager
 def mount(
-        what: PathString,
-        where: Path,
-        operation: Optional[str] = None,
-        options: Sequence[str] = (),
-        type: Optional[str] = None,
-        read_only: bool = False,
+    what: PathString,
+    where: Path,
+    operation: Optional[str] = None,
+    options: Sequence[str] = (),
+    type: Optional[str] = None,
+    read_only: bool = False,
 ) -> Iterator[Path]:
     os.makedirs(where, 0o755, True)
 
@@ -63,7 +82,7 @@ def mount(
         run(["umount", "--no-mtab", "--recursive", where])
 
 
-def mount_bind(what: Path, where: Optional[Path] = None) -> ContextManager[Path]:
+def mount_bind(what: Path, where: Optional[Path] = None, read_only: bool = False) -> ContextManager[Path]:
     if where is None:
         where = what
 
@@ -72,10 +91,6 @@ def mount_bind(what: Path, where: Optional[Path] = None) -> ContextManager[Path]
     return mount(what, where, operation="--bind")
 
 
-def mount_tmpfs(where: Path) -> ContextManager[Path]:
-    return mount("tmpfs", where, type="tmpfs")
-
-
 @contextlib.contextmanager
 def mount_overlay(
     lower: Path,
@@ -93,17 +108,6 @@ def mount_overlay(
             delete_whiteout_files(upper)
 
 
-@contextlib.contextmanager
-def mount_api_vfs(root: Path) -> Iterator[None]:
-    subdirs = ("proc", "dev", "sys")
-
-    with complete_step("Mounting API VFS…", "Unmounting API VFS…"), contextlib.ExitStack() as stack:
-        for subdir in subdirs:
-            stack.enter_context(mount_bind(Path("/") / subdir, root / subdir))
-
-        yield
-
-
 @contextlib.contextmanager
 def dissect_and_mount(image: Path, where: Path) -> Iterator[Path]:
     run(["systemd-dissect", "-M", image, where])
diff --git a/mkosi/run.py b/mkosi/run.py
new file mode 100644 (file)
index 0000000..bcae2b6
--- /dev/null
@@ -0,0 +1,325 @@
+import ctypes
+import ctypes.util
+import multiprocessing
+import os
+import pwd
+import shlex
+import signal
+import subprocess
+import sys
+import traceback
+from pathlib import Path
+from types import TracebackType
+from typing import Any, Callable, Iterable, Mapping, Optional, Sequence, Type, TypeVar
+
+from mkosi.backend import MkosiState
+from mkosi.log import ARG_DEBUG, MkosiPrinter, die
+from mkosi.types import _FILE, CompletedProcess, PathString, Popen
+
+CLONE_NEWNS = 0x00020000
+CLONE_NEWUSER = 0x10000000
+
+SUBRANGE = 65536
+
+T = TypeVar("T")
+
+
+def unshare(flags: int) -> None:
+    libc_name = ctypes.util.find_library("c")
+    if libc_name is None:
+        die("Could not find libc")
+    libc = ctypes.CDLL(libc_name, use_errno=True)
+
+    if libc.unshare(ctypes.c_int(flags)) != 0:
+        e = ctypes.get_errno()
+        raise OSError(e, os.strerror(e))
+
+
+def read_subrange(path: Path) -> int:
+    uid = str(os.getuid())
+    try:
+        user = pwd.getpwuid(os.getuid()).pw_name
+    except KeyError:
+        user = None
+
+    for line in path.read_text().splitlines():
+        name, start, count = line.split(":")
+
+        if name == uid or name == user:
+            break
+    else:
+        die(f"No mapping found for {user or uid} in {path}")
+
+    if int(count) < SUBRANGE:
+        die(f"subuid/subgid range length must be at least {SUBRANGE}, got {count} for {user or uid} from line '{line}'")
+
+    return int(start)
+
+
+def become_root() -> tuple[int, int]:
+    """
+    Set up a new user namespace mapping using /etc/subuid and /etc/subgid.
+
+    The current user will be mapped to root and 65436 will be mapped to the UID/GID of the invoking user.
+    The other IDs will be mapped through.
+
+    The function returns the UID-GID pair of the invoking user in the namespace (65436, 65436).
+    """
+    subuid = read_subrange(Path("/etc/subuid"))
+    subgid = read_subrange(Path("/etc/subgid"))
+
+    event = multiprocessing.Event()
+    pid = os.getpid()
+
+    child = os.fork()
+    if child == 0:
+        event.wait()
+
+        # We map the private UID range configured in /etc/subuid and /etc/subgid into the container using
+        # newuidmap and newgidmap. On top of that, we also make sure to map in the user running mkosi so that
+        # we can run still chown stuff to that user or run stuff as that user which will make sure any
+        # generated files are owned by that user. We don't map to the last user in the range as the last user
+        # is sometimes used in tests as a default value and mapping to that user might break those tests.
+        newuidmap = [
+            "newuidmap", pid,
+            0, subuid, SUBRANGE - 100,
+            SUBRANGE - 100, os.getuid(), 1,
+            SUBRANGE - 100 + 1, subuid + SUBRANGE - 100 + 1, 99
+        ]
+        run((str(x) for x in newuidmap))
+
+        newgidmap = [
+            "newgidmap", pid,
+            0, subgid, SUBRANGE - 100,
+            SUBRANGE - 100, os.getgid(), 1,
+            SUBRANGE - 100 + 1, subgid + SUBRANGE - 100 + 1, 99
+        ]
+        run(str(x) for x in newgidmap)
+
+        sys.stdout.flush()
+        sys.stderr.flush()
+
+        os._exit(0)
+
+    unshare(CLONE_NEWUSER)
+    event.set()
+    os.waitpid(child, 0)
+
+    # By default, we're root in the user namespace because if we were our current user by default, we
+    # wouldn't be able to chown stuff to be owned by root while the reverse is possible.
+    os.setresuid(0, 0, 0)
+    os.setresgid(0, 0, 0)
+    os.setgroups([0])
+
+    return SUBRANGE - 100, SUBRANGE - 100
+
+
+def init_mount_namespace() -> None:
+    unshare(CLONE_NEWNS)
+    run(["mount", "--make-rslave", "/"])
+
+
+def foreground() -> None:
+    """
+    If we're connected to a terminal, put the process in a new process group and make that the foreground
+    process group so that only this process receives SIGINT.
+    """
+    if sys.stdin.isatty():
+        os.setpgrp()
+        old = signal.signal(signal.SIGTTOU, signal.SIG_IGN)
+        os.tcsetpgrp(0, os.getpgrp())
+        signal.signal(signal.SIGTTOU, old)
+
+
+class RemoteException(Exception):
+    """
+    Stores the exception from a subprocess along with its traceback. We have to do this explicitly because
+    the original traceback object cannot be pickled. When stringified, produces the subprocess stacktrace
+    plus the exception message.
+    """
+    def __init__(self, e: BaseException, tb: traceback.StackSummary):
+        self.exception = e
+        self.tb = tb
+
+    def __str__(self) -> str:
+        return f"Traceback (most recent call last):\n{''.join(self.tb.format()).strip()}\n{type(self.exception).__name__}: {self.exception}"
+
+
+def excepthook(exctype: Type[BaseException], exc: BaseException, tb: Optional[TracebackType]) -> None:
+    """Attach to sys.excepthook to automically format exceptions with a RemoteException attached correctly."""
+    if isinstance(exc.__cause__, RemoteException):
+        print(exc.__cause__, file=sys.stderr)
+    else:
+        sys.__excepthook__(exctype, exc, tb)
+
+
+def fork_and_wait(target: Callable[[], T]) -> T:
+    """Run the target function in the foreground in a child process and collect its backtrace if there is one."""
+    pout, pin = multiprocessing.Pipe(duplex=False)
+
+    pid = os.fork()
+    if pid == 0:
+        foreground()
+
+        try:
+            result = target()
+        except BaseException as e:
+            # Just getting the stacktrace from the traceback doesn't get us the parent frames for some reason
+            # so we have to attach those manually.
+            tb = traceback.StackSummary.from_list(traceback.extract_stack()[:-1] + traceback.extract_tb(e.__traceback__))
+            pin.send(RemoteException(e, tb))
+        else:
+            pin.send(result)
+        finally:
+            pin.close()
+
+        sys.stdout.flush()
+        sys.stderr.flush()
+
+        os._exit(0)
+
+    os.waitpid(pid, 0)
+    result = pout.recv()
+    if isinstance(result, RemoteException):
+        # Reraise the original exception and attach the remote exception with full traceback as the cause.
+        raise result.exception from result
+
+    return result
+
+
+def run(
+    cmdline: Iterable[PathString],
+    check: bool = True,
+    stdout: _FILE = None,
+    stderr: _FILE = None,
+    env: Optional[Mapping[str, Any]] = None,
+    **kwargs: Any,
+) -> CompletedProcess:
+    cmdline = [os.fspath(x) for x in cmdline]
+
+    if "run" in ARG_DEBUG:
+        MkosiPrinter.info(f"+ {shlex.join(str(s) for s in cmdline)}")
+
+    if not stdout and not stderr:
+        # Unless explicit redirection is done, print all subprocess
+        # output on stderr, since we do so as well for mkosi's own
+        # output.
+        stdout = sys.stderr
+
+    if env is None:
+        env = os.environ
+    else:
+        env = dict(
+            PATH=os.environ["PATH"],
+            TERM=os.getenv("TERM", "vt220"),
+        ) | env
+
+    try:
+        return subprocess.run(cmdline, check=check, stdout=stdout, stderr=stderr, env=env, **kwargs,
+                              preexec_fn=foreground)
+    except FileNotFoundError:
+        die(f"{cmdline[0]} not found in PATH.")
+
+
+def spawn(
+    cmdline: Sequence[PathString],
+    stdout: _FILE = None,
+    stderr: _FILE = None,
+    **kwargs: Any,
+) -> Popen:
+    if "run" in ARG_DEBUG:
+        MkosiPrinter.info(f"+ {shlex.join(str(s) for s in cmdline)}")
+
+    if not stdout and not stderr:
+        # Unless explicit redirection is done, print all subprocess
+        # output on stderr, since we do so as well for mkosi's own
+        # output.
+        stdout = sys.stderr
+
+    try:
+        return subprocess.Popen(cmdline, stdout=stdout, stderr=stderr, **kwargs, preexec_fn=foreground)
+    except FileNotFoundError:
+        die(f"{cmdline[0]} not found in PATH.")
+
+
+def run_with_apivfs(
+    state: MkosiState,
+    cmd: Sequence[PathString],
+    bwrap_params: Sequence[PathString] = tuple(),
+    stdout: _FILE = None,
+    env: Mapping[str, Any] = {},
+) -> CompletedProcess:
+    cmdline: list[PathString] = [
+        "bwrap",
+        # Required to make chroot detection via /proc/1/root work properly.
+        "--unshare-pid",
+        "--dev-bind", "/", "/",
+        "--tmpfs", state.root / "run",
+        "--tmpfs", state.root / "tmp",
+        "--proc", state.root / "proc",
+        "--dev", state.root / "dev",
+        "--ro-bind", "/sys", state.root / "sys",
+        "--bind", state.var_tmp(), state.root / "var/tmp",
+        *bwrap_params,
+        "sh", "-c",
+    ]
+
+    env = env | state.environment
+
+    template = f"chmod 1777 {state.root / 'tmp'} {state.root / 'var/tmp'} {state.root / 'dev/shm'} && exec {{}} || exit $?"
+
+    try:
+        return run([*cmdline, template.format(shlex.join(str(s) for s in cmd))],
+                   text=True, stdout=stdout, env=env)
+    except subprocess.CalledProcessError as e:
+        if "run" in ARG_DEBUG:
+            run([*cmdline, template.format("sh")], check=False, env=env)
+        die(f"\"{shlex.join(str(s) for s in cmd)}\" returned non-zero exit code {e.returncode}.")
+
+
+def run_workspace_command(
+    state: MkosiState,
+    cmd: Sequence[PathString],
+    bwrap_params: Sequence[PathString] = tuple(),
+    network: bool = False,
+    stdout: _FILE = None,
+    env: Mapping[str, Any] = {},
+) -> CompletedProcess:
+    cmdline: list[PathString] = [
+        "bwrap",
+        "--unshare-ipc",
+        "--unshare-pid",
+        "--unshare-cgroup",
+        "--bind", state.root, "/",
+        "--tmpfs", "/run",
+        "--tmpfs", "/tmp",
+        "--dev", "/dev",
+        "--proc", "/proc",
+        "--ro-bind", "/sys", "/sys",
+        "--bind", state.var_tmp(), "/var/tmp",
+        *bwrap_params,
+    ]
+
+    if network:
+        # If we're using the host network namespace, use the same resolver
+        cmdline += ["--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf"]
+    else:
+        cmdline += ["--unshare-net"]
+
+    cmdline += ["sh", "-c"]
+
+    env = dict(
+        container="mkosi",
+        SYSTEMD_OFFLINE=str(int(network)),
+        HOME="/",
+    ) | env | state.environment
+
+    template = "chmod 1777 /tmp /var/tmp /dev/shm && exec {} || exit $?"
+
+    try:
+        return run([*cmdline, template.format(shlex.join(str(s) for s in cmd))],
+                   text=True, stdout=stdout, env=env)
+    except subprocess.CalledProcessError as e:
+        if "run" in ARG_DEBUG:
+            run([*cmdline, template.format("sh")], check=False, env=env)
+        die(f"\"{shlex.join(str(s) for s in cmd)}\" returned non-zero exit code {e.returncode}.")
diff --git a/mkosi/types.py b/mkosi/types.py
new file mode 100644 (file)
index 0000000..f544784
--- /dev/null
@@ -0,0 +1,20 @@
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import IO, TYPE_CHECKING, Any, Union
+
+# These types are only generic during type checking and not at runtime, leading
+# to a TypeError during compilation.
+# Let's be as strict as we can with the description for the usage we have.
+if TYPE_CHECKING:
+    CompletedProcess = subprocess.CompletedProcess[Any]
+    Popen = subprocess.Popen[Any]
+    TempDir = tempfile.TemporaryDirectory[str]
+else:
+    CompletedProcess = subprocess.CompletedProcess
+    Popen = subprocess.Popen
+    TempDir = tempfile.TemporaryDirectory
+
+# Borrowed from https://github.com/python/typeshed/blob/3d14016085aed8bcf0cf67e9e5a70790ce1ad8ea/stdlib/3/subprocess.pyi#L24
+_FILE = Union[None, int, IO[Any]]
+PathString = Union[Path, str]
index b110acdb1e812e1b850fd2ca3911eac02fe3c3a7..afbcc46e769c7d994940e6ca5e3d2264ad0ccf00 100644 (file)
@@ -9,13 +9,13 @@ import pytest
 
 from mkosi.backend import (
     Distribution,
-    MkosiException,
     PackageType,
     safe_tar_extract,
     set_umask,
     strip_suffixes,
     workspace,
 )
+from mkosi.log import MkosiException
 
 
 def test_distribution() -> None:
index f744714af2b72b526717bd13ec0baded8e60312d..a28f9915f5f8f38e7f3a5ddc8d654f8259fe7636 100644 (file)
@@ -11,7 +11,8 @@ from typing import Iterator, List, Optional
 import pytest
 
 import mkosi
-from mkosi.backend import Distribution, MkosiConfig, MkosiException, Verb
+from mkosi.backend import Distribution, MkosiConfig, Verb
+from mkosi.log import MkosiException
 
 
 def parse(argv: Optional[List[str]] = None) -> MkosiConfig: