From: Daan De Meyer Date: Wed, 26 Jul 2023 14:33:57 +0000 (+0200) Subject: Run scripts on the host by default X-Git-Tag: v15~44^2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=refs%2Fpull%2F1701%2Fhead;p=thirdparty%2Fmkosi.git Run scripts on the host by default Scripts currently run chrooted in the image. This is not great because it means the tool you want to run from a script has to be installed in the image, even if it has --root support to run from outside the image. Specifically, to install extra packages from a script, you currently have to install the package manager inside the image itself. Even then, it might be completely different than the package manager on the host (both in version and in build options), possibly leading to all kinds of weird issues. To allow users to install extra packages from scripts, we now default to running scripts on the host. Additionally, to make life easy for users, we provide a set of scripts in the PATH for the package managers we support that call them with the necessary options to install packages in the same way mkosi installs packages into the root directory. This means all that users have to do is "dnf install xxx" from the script to install a new package into the root directory. Because some tools do not have a --root option and to provide an easy migration for users that depend on scripts running in the image, we also put a script "mkosi-chroot" in the PATH which uses bubblewrap to chroot into the image as we did before. Users can keep their old scripts working by simply adding the following to the top of their script: ``` if [ "$container" != "mkosi" ]; then exec mkosi-chroot $SCRIPT "$@" fi ``` When running scripts on the host, no APIVFS directories are mounted into the image. When using the "mkosi-chroot" and package manager scripts, the APIVFS directories are automatically mounted before executing the corresponding command. The apivfs_cmd() function is introduced to make implementing this easier. Additionally, we now always consider the current working directory a BuildSources= directory. BuildSources= is now used to declare additional source directories. The current working directory can always be overmounted with another directory by simply specifying a source directory in BuildSources= without a target directory. To allow scripts running on the host to find the image, we set the BUILDROOT variable for all scripts. To prevent scripts on the host from messing with the host system when mkosi is running as root, we extend the sandboxing to cover many more directories which are all mounted read-only while a script is executing. This change also allows scripts to be written in python or other scripting languages without having to install python into the image itself. --- diff --git a/NEWS.md b/NEWS.md index f8e9ba50e..788f74f0a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -87,6 +87,22 @@ this doesn't work properly as it might result in leftover files in the install directory from a previous installation, so we have to empty the directory before reusing it, invalidating the caching, so the option was removed. +- Build scripts are now executed on the host. See the `SCRIPTS` section + in the manual for more information. Existing build scripts will need + to be updated to make sure they keep working. Specifically, most paths + in scripts will need to be prefixed with $BUILDROOT to have them + operate on the image instead of on the host system. To ensure the host + system cannot be modified when running a script, most host directories + are mounted read-only when running a script to ensure a script cannot + modify the host in any way. Alternatively to making the script run on + the host, the script can also still be executed in the image itself by + putting the following snippet at the top of the script: + + ```sh + if [ "$container" != "mkosi" ]; then + exec mkosi-chroot $SCRIPT "$@" + fi + ``` ## v14 diff --git a/mkosi.md b/mkosi.md index 35b0cbac1..8771e0c74 100644 --- a/mkosi.md +++ b/mkosi.md @@ -188,40 +188,6 @@ Those settings cannot be configured in the configuration files. each build in a series will have a version number one higher then the previous one. -## Execution Flow - -Execution flow for `mkosi build`. Default values/calls are shown in parentheses. -When building with `--incremental` mkosi creates a cache of the distribution -installation if not already existing and replaces the distribution installation -in consecutive runs with data from the cached one. - -- Copy package manager trees into the workspace -* Copy base trees (`--base-tree=`) into the image -* Copy skeleton trees (`mkosi.skeleton`) into image -* Install distribution and packages into image or use cache tree if - available -* Run prepare script on image with the `final` argument (`mkosi.prepare`) -* Install build packages in overlay if a build script is configured -* Run prepare script on overlay with the `build` argument if a build - script is configured (`mkosi.prepare`) -* Cache the image if configured (`--incremental`) -* Run build script on image + overlay if a build script is configured (`mkosi.build`) -* Finalize the build if the output format `none` is configured -* Copy the build script outputs into the image -* Copy the extra trees into the image (`mkosi.extra`) -* Install systemd-boot and configure secure boot if configured (`--secure-boot`) -* Run post-install script (`mkosi.postinst`) -* Run `systemd-sysusers` -* Run `systemctl preset-all` -* Run `depmod` -* Run `systemd-firstboot` -* Run `systemd-hwdb` -* Remove packages and files (`RemovePackages=`, `RemoveFiles=`) -* Run finalize script (`mkosi.finalize`) -* Run SELinux relabel is a SELinux policy is installed -* Generate unified kernel image if configured to do so -* Generate final output format - ## Supported output formats The following output formats are supported: @@ -749,48 +715,22 @@ they should be specified with a boolean argument: either "1", "yes", or "true" t `BuildScript=`, `--build-script=` : Takes a path to an executable that is used as build script for this - image. The specified script is mounted into the image with the build - overlay mounted on top. 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 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. + image. See the `SCRIPTS` section for more information. `PrepareScript=`, `--prepare-script=` -: Takes a path to an executable that is invoked with the `final` - argument inside the image right after installing the software - packages. It is the last step before the image is cached (if - incremental mode is enabled). If a build script is provided, this - script is also invoked with the `build` argument inside the image with - the build overlay mounted right after installing the packages - configured with `BuildPackages=`. This script is 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. +: Takes a path to an executable that is used as the prepare script for + this image. See the `SCRIPTS` section for more information. `PostInstallationScript=`, `--postinst-script=` -: Takes a path to an executable that is invoked inside the image right - after copying in the build artifacts generated by the build script - (if configured). 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. +: Takes a path to an executable that is used as the post-installation + script for this image. See the `SCRIPTS` section for more information. `FinalizeScript=`, `--finalize-script=` -: Takes a path to an executable that is invoked outside the image after - the build is finished but before the image is packaged. This script is - invoked directly in the host environment, and hence has full access to - the host's resources. If this option is not used, but an executable - `mkosi.finalize` is found in the local directory, it is automatically - used for this purpose. Specify an empty value to disable automatic - detection. +: Takes a path to an executable that is used as the finalize script for + this image. See the `SCRIPTS` section for more information. `WithNetwork=`, `--with-network=` @@ -1082,6 +1022,180 @@ images. Any distribution that packages `zypper` may be used to build Currently, *Fedora Linux* packages all relevant tools as of Fedora 28. +# Execution Flow + +Execution flow for `mkosi build`. Default values/calls are shown in parentheses. +When building with `--incremental` mkosi creates a cache of the distribution +installation if not already existing and replaces the distribution installation +in consecutive runs with data from the cached one. + +* Parse CLI options +* Parse configuration files +* If we're not running as root, unshare the user namespace and map the + subuid range configured in /etc/subuid and /etc/subgid into it. +* Unshare the mount namespace +* Remount the following directories read-only if they exist: + - /usr + - /etc + - /opt + - /srv + - /boot + - /efi + - /media + - /mnt + +Then, for each preset, we execute the following steps: + +* Copy package manager trees into the workspace +* Copy base trees (`--base-tree=`) into the image +* Copy skeleton trees (`mkosi.skeleton`) into image +* Install distribution and packages into image or use cache tree if + available +* Run prepare script on image with the `final` argument (`mkosi.prepare`) +* Install build packages in overlay if a build script is configured +* Run prepare script on overlay with the `build` argument if a build + script is configured (`mkosi.prepare`) +* Cache the image if configured (`--incremental`) +* Run build script on image + overlay if a build script is configured (`mkosi.build`) +* Finalize the build if the output format `none` is configured +* Copy the build script outputs into the image +* Copy the extra trees into the image (`mkosi.extra`) +* Install systemd-boot and configure secure boot if configured (`--secure-boot`) +* Run post-install script (`mkosi.postinst`) +* Run `systemd-sysusers` +* Run `systemctl preset-all` +* Run `depmod` +* Run `systemd-firstboot` +* Run `systemd-hwdb` +* Remove packages and files (`RemovePackages=`, `RemoveFiles=`) +* Run finalize script (`mkosi.finalize`) +* Run SELinux relabel is a SELinux policy is installed +* Generate unified kernel image if configured to do so +* Generate final output format + +# Scripts + +To allow for image customization that cannot be implemented using +mkosi's builtin features, mkosi supports running scripts at various +points during the image build process that can customize the image as +needed. Scripts are executed on the host system with a customized +environment to simplify modifying the image. For each script, the +configured build sources (`BuildSources=`) are mounted into the current +working directory before running the script and `$SRCDIR` is set to +point to the current working directory. The following scripts are +supported: + +* If **`mkosi.prepare`** (`PrepareScript=`) exists, it is first called + with the `final` argument, right after the software packages are + installed. It is called a second time with the `build` command line + parameter, right after the build packages are installed and the build + overlay mounted on top of the image's root directory . This script has + network access and may be used to install packages from other sources + than the distro's package manager (e.g. `pip`, `npm`, ...), after all + software packages are installed but before the image is cached (if + incremental mode is enabled). In contrast to a general purpose + installation, it is safe to install packages to the system + (`pip install`, `npm install -g`) instead of in `$SRCDIR` itself + because the build image is only used for a single project and can + easily be thrown away and rebuilt so there's no risk of conflicting + dependencies and no risk of polluting the host system. + +* If **`mkosi.build`** (`BuildScript=`) exists, it is executed with the + build overlay mounted on top of the image's root directory. When + running the build script, `$DESTDIR` points to a directory where the + script should place any files generated it would like to end up in the + 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 running the build script, + the contents of `$DESTDIR` are copied into the image. + +* If **`mkosi.postinst`** (`PostInstallationScript=`) exists, it is + executed after the (optional) build tree and extra trees have been + installed. This script may be used to alter the images without any + restrictions, after all software packages and built sources have been + installed. + +* If **`mkosi.finalize`** (`FinalizeScript=`) exists, it is executed as + the last step of preparing an image. + +Scripts executed by mkosi receive the following environment variables: + +* `$SCRIPT` contains the path to the running script relative to the + image root directory. The primary usecase for this variable is in + combination with the `mkosi-chroot` script. See the description of + `mkosi-chroot` below for more information. + +* `$SRCDIR` contains the path to the directory mkosi was invoked from, + with any configured build sources mounted on top. + +* `$BUILDDIR` is only defined if `mkosi.builddir` exists and points to + the build directory to use. This is useful for all build systems that + support out-of-tree builds to reuse already built artifacts from + previous runs. + +* `$DESTDIR` is a directory into which any installed software generated + by the build script may be placed. This variable is only set when + executing the build script. + +* `$OUTPUTDIR` points to the staging directory used to store build + artifacts generated during the build. + +* `$BUILDROOT` is the root directory of the image being built, + optionally with the build overlay mounted on top depending on the + script that's being executed. + +* `$WITH_DOCS` is either `0` or `1` depending on whether a build + without or with installed documentation was requested + (`WithDocs=yes`). The build script should suppress installation of + any package documentation to `$DESTDIR` in case `$WITH_DOCS` is set + to `0`. + +* `$WITH_TESTS` is either `0`or `1` depending on whether a build + without or with running the test suite was requested + (`WithTests=no`). The build script should avoid running any unit or + integration tests in case `$WITH_TESTS` is `0`. + +* `$WITH_NETWORK` is either `0`or `1` depending on whether a build + without or with networking is being executed (`WithNetwork=no`). + The build script should avoid any network communication in case + `$WITH_NETWORK` is `0`. + +Additionally, when a script is executed, a few scripts are made +available via `$PATH` to simplify common usecases. + +* `mkosi-chroot`: This script will chroot into the image and execute the + given command. On top of chrooting into the image, it will also mount + various files and directories (`$SRCDIR`, `$DESTDIR`, `$BUILDDIR`, + `$OUTPUTDIR`, `$SCRIPT`) into the image and modify the corresponding + environment variables to point to the locations inside the image. It + will also mount APIVFS filesystems (`/proc`, `/dev`, ...) to make sure + scripts and tools executed inside the chroot work properly. It also + propagates `/etc/resolv.conf` from the host into the chroot if + requested so that DNS resolution works inside the chroot. After the + mkosi-chroot command exits, various mount points are cleaned up. + + To execute the entire script inside the image, put the following + snippet at the start of the script: + + ```sh + if [ "$container" != "mkosi" ]; then + exec mkosi-chroot $SCRIPT "$@" + fi + ``` + +* For all of the supported package managers except portage (`dnf`, + `apt`, `pacman`, `zypper`), scripts of the same name are put into + `$PATH` that make sure these commands operate on the image's root + directory with the configuration supplied by the user instead of on + the host system. This means that from a script, you can do e.g. + `dnf install vim` to install vim into the image. + +When scripts are executed, any directories that are still writable are +also made read-only (/home, /var, /root, ...) and only the minimal set +of directories that need to be writable remain writable. This is to +ensure that scripts can't mess with the host system when mkosi is +running as root. + # Files To make it easy to build images for development versions of your @@ -1111,45 +1225,6 @@ local directory: copied will be owned by root. To preserve ownership, use a tar archive. -* **`mkosi.build`** may be an executable script. If it exists, the build - sources (`BuildSources=`) are mounted into the image with the build - overlay mounted on top, along with the `mkosi.build` script. The - script is then invoked inside the build overlay, 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 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 running the build script, - the contents of `$DESTDIR` are copied into the image. - - The `$MKOSI_CONFIG` environment variable will be set inside of this - script so that you know which `mkosi.conf` (if any) was passed in. - -* The **`mkosi.prepare`** script is invoked directly after the software packages are installed, from within - the image context, if it exists. It is first called for the image with the `final` command line argument, - right after the software packages are installed. It is called a second time for the build overlay (if - this is enabled, see above) with the `build` command line parameter, right after the build packages are - installed and before the build script is executed. This script has network access and may be used to - install packages from other sources than the distro's package manager (e.g. `pip`, `npm`, ...), after all - software packages are installed but before the image is cached (if incremental mode is enabled). This - script is executed within `$SRCDIR`. In contrast to a general purpose installation, it is safe to install - packages to the system ( `pip install`, `npm install -g`) instead of in `$SRCDIR` itself because the build - image is only used for a single project and can easily be thrown away and rebuilt so there's no risk of - conflicting dependencies and no risk of polluting the host system. - -* The **`mkosi.postinst`** script is invoked as the penultimate step of preparing an image, from within the - image context. This script may be used to alter the images without any restrictions, after all software - packages and built sources have been installed. Note that this script is executed directly in the image - context with the final root directory in place, without any `$SRCDIR`/`$DESTDIR` setup. - -* The **`mkosi.finalize`** script, if it exists, is invoked as the last step of preparing an image, from the - host system. The environment variable `$BUILDROOT` points to the root directory of the installation image. - This script may be used to alter the images without any restrictions, after all software packages and built - sources have been installed. This script is more flexible than `mkosi.postinst` in two regards: it has - access to the host file system so it's easier to copy in additional files or to modify the image based on - external configuration, and the script is run in the host, so it can be used even without emulation even if - the image has a foreign architecture. - * The **`mkosi.nspawn`** nspawn settings file will be copied into the same place as the output image file, if it exists. This is useful since nspawn looks for settings files next to image files it boots, for additional container runtime settings. @@ -1238,38 +1313,6 @@ be recompiled. # ENVIRONMENT VARIABLES -The build script `mkosi.build` receives the following environment -variables: - -* `$SRCDIR` contains the path to the sources to build. - -* `$DESTDIR` is a directory into which any installed software generated - by the build script may be placed. - -* `$BUILDDIR` is only defined if `mkosi.builddir` and points to the - build directory to use. This is useful for all build systems that - support out-of-tree builds to reuse already built artifacts from - previous runs. - -* `$OUTPUTDIR` is the staging directory used to store build artifacts - generated by the build script. - -* `$WITH_DOCS` is either `0` or `1` depending on whether a build - without or with installed documentation was requested - (`WithDocs=yes`). The build script should suppress installation of - any package documentation to `$DESTDIR` in case `$WITH_DOCS` is set - to `0`. - -* `$WITH_TESTS` is either `0`or `1` depending on whether a build - without or with running the test suite was requested - (`WithTests=no`). The build script should avoid running any unit or - integration tests in case `$WITH_TESTS` is `0`. - -* `$WITH_NETWORK` is either `0`or `1` depending on whether a build - without or with networking is being executed (`WithNetwork=no`). - The build script should avoid any network communication in case - `$WITH_NETWORK` is `0`. - * `$MKOSI_LESS` overrides options for `less` when it is invoked by `mkosi` to page output. @@ -1320,6 +1363,11 @@ BuildPackages=make,gcc,libcurl-devel EOF $ cat >mkosi.build < None: with complete_step(f"Installing {str(state.config.distribution).capitalize()}"): state.config.distribution.install(state) + if not (state.root / "etc/machine-id").exists(): + # Uninitialized means we want it to get initialized on first boot. + (state.root / "etc/machine-id").write_text("uninitialized\n") + (state.root / "etc/machine-id").chmod(0o0444) + # Ensure /efi exists so that the ESP is mounted there, as recommended by # https://0pointer.net/blog/linux-boot-partitions.html. Use the most restrictive access mode we # can without tripping up mkfs tools since this directory is only meant to be overmounted and @@ -108,6 +120,12 @@ def install_distribution(state: MkosiState) -> None: if state.config.packages: state.config.distribution.install_packages(state, state.config.packages) + for f in ("var/lib/systemd/random-seed", "var/lib/systemd/credential.secret", "etc/machine-info"): + # Using missing_ok=True still causes an OSError if the mount is read-only even if the + # file doesn't exist so do an explicit exists() check first. + if (state.root / f).exists(): + (state.root / f).unlink() + def install_build_packages(state: MkosiState) -> None: if not need_build_packages(state.config): @@ -164,14 +182,22 @@ def mount_build_overlay(state: MkosiState, read_only: bool = False) -> ContextMa return mount_overlay([state.root], state.workspace / "build-overlay", state.root, read_only) -def finalize_sources(config: MkosiConfig) -> list[tuple[Path, Path]]: +def finalize_source_mounts(config: MkosiConfig) -> list[PathString]: sources = [ - (src, Path("work/src") / (str(target).lstrip("/") if target else ".")) + (src, Path.cwd() / (str(target).lstrip("/") if target else ".")) for src, target - in config.build_sources + in ((Path.cwd(), None), *config.build_sources) ] - return sorted(sources, key=lambda s: s[1]) + return flatten(["--bind", src, target] for src, target in sorted(sources, key=lambda s: s[1])) + + +def finalize_writable_mounts(config: MkosiConfig) -> list[PathString]: + """ + bwrap() mounts /home and /var read-only during execution. This functions finalizes the bind mount options + for the directories that could be in /home or /var that we do need to be writable. + """ + return flatten(["--bind", d, d] for d in (config.workspace_dir, config.cache_dir, config.output_dir, config.build_dir) if d) def run_prepare_script(state: MkosiState, build: bool) -> None: @@ -180,80 +206,86 @@ def run_prepare_script(state: MkosiState, build: bool) -> None: if build and state.config.build_script is None: return - options: list[PathString] = [ - "--bind", state.config.prepare_script, "/work/prepare", - "--chdir", "/work/src", - ] + env = dict( + SCRIPT="/work/prepare", + SRCDIR=str(Path.cwd()), + BUILDROOT=str(state.root), + ) - for src, target in finalize_sources(state.config): - options += ["--bind", src, Path("/") / target] + chroot: list[PathString] = chroot_cmd( + state.root, + options=[ + "--bind", state.config.prepare_script, "/work/prepare", + "--bind", Path.cwd(), "/work/src", + "--chdir", "/work/src", + "--setenv", "SRCDIR", "/work/src", + "--setenv", "BUILDROOT", "/", + ], + network=True, + ) if build: with complete_step("Running prepare script in build overlay…"), mount_build_overlay(state): bwrap( - ["chroot", "/work/prepare", "build"], - apivfs=state.root, - scripts=dict(chroot=chroot_cmd(state.root, options=options, network=True)), - env=dict(SRCDIR="/work/src") | state.config.environment, + [state.config.prepare_script, "build"], + options=finalize_source_mounts(state.config) + finalize_writable_mounts(state.config), + scripts={"mkosi-chroot": chroot} | package_manager_scripts(state), + env=env | state.config.environment, stdin=sys.stdin, ) - shutil.rmtree(state.root / "work") else: with complete_step("Running prepare script…"): bwrap( - ["chroot", "/work/prepare", "final"], - apivfs=state.root, - scripts=dict(chroot=chroot_cmd(state.root, options=options, network=True)), - env=dict(SRCDIR="/work/src") | state.config.environment, + [state.config.prepare_script, "final"], + options=finalize_source_mounts(state.config) + finalize_writable_mounts(state.config), + scripts={"mkosi-chroot": chroot} | package_manager_scripts(state), + env=env | state.config.environment, stdin=sys.stdin, ) - shutil.rmtree(state.root / "work") def run_build_script(state: MkosiState) -> None: if state.config.build_script is None: return - # Create a few necessary mount points inside the build overlay. - with mount_build_overlay(state): - state.root.joinpath("work").mkdir(mode=0o755, exist_ok=True) - state.root.joinpath("work/src").mkdir(mode=0o755, exist_ok=True) - state.root.joinpath("work/dest").mkdir(mode=0o755, exist_ok=True) - state.root.joinpath("work/out").mkdir(mode=0o755, exist_ok=True) - state.root.joinpath("work/build-script").touch(mode=0o755, exist_ok=True) - state.root.joinpath("work/build").mkdir(mode=0o755, exist_ok=True) + 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), + SCRIPT="/work/build-script", + SRCDIR=str(Path.cwd()), + DESTDIR=str(state.install_dir), + OUTPUTDIR=str(state.staging), + BUILDROOT=str(state.root), + ) - for _, target in finalize_sources(state.config): - state.root.joinpath(target).mkdir(mode=0o755, exist_ok=True, parents=True) + if state.config.build_dir is not None: + env |= dict(BUILDDIR=str(state.config.build_dir)) - with complete_step("Running build script…"), mount_build_overlay(state, read_only=True): - options: list[PathString] = [ + chroot = chroot_cmd( + state.root, + options=[ "--bind", state.config.build_script, "/work/build-script", "--bind", state.install_dir, "/work/dest", "--bind", state.staging, "/work/out", + "--bind", Path.cwd(), "/work/src", + *(["--bind", str(state.config.build_dir), "/work/build"] if state.config.build_dir else []), "--chdir", "/work/src", - ] - - for src, target in finalize_sources(state.config): - options += ["--bind", src, Path("/") / target] - - 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), - SRCDIR="/work/src", - DESTDIR="/work/dest", - OUTPUTDIR="/work/out", - ) - - if state.config.build_dir is not None: - options += ["--bind", state.config.build_dir, "/work/build"] - env |= dict(BUILDDIR="/work/build") + "--setenv", "SRCDIR", "/work/src", + "--setenv", "DESTDIR", "/work/dest", + "--setenv", "OUTPUTDIR", "/work/out", + "--setenv", "BUILDROOT", "/", + *(["--setenv", "BUILDDIR", "/work/build"] if state.config.build_dir else []), + "--remount-ro", "/", + ], + network=state.config.with_network, + ) + with complete_step("Running build script…"), mount_build_overlay(state): bwrap( - ["chroot", "/work/build-script"], - apivfs=state.root, - scripts=dict(chroot=chroot_cmd(state.root, options=options, network=state.config.with_network)), + [state.config.build_script], + options=finalize_source_mounts(state.config) + finalize_writable_mounts(state.config), + scripts={"mkosi-chroot": chroot} | package_manager_scripts(state), env=env | state.config.environment, stdin=sys.stdin, ) @@ -263,31 +295,70 @@ def run_postinst_script(state: MkosiState) -> None: if state.config.postinst_script is None: return + env = dict( + SCRIPT="/work/postinst", + SRCDIR=str(Path.cwd()), + OUTPUTDIR=str(state.staging), + BUILDROOT=str(state.root), + ) + + chroot = chroot_cmd( + state.root, + options=[ + "--bind", state.config.postinst_script, "/work/postinst", + "--bind", state.staging, "/work/out", + "--bind", Path.cwd(), "/work/src", + "--chdir", "/work/src", + "--setenv", "SRCDIR", "/work/src", + "--setenv", "OUTPUTDIR", "/work/out", + "--setenv", "BUILDROOT", "/", + ], + network=state.config.with_network, + ) + with complete_step("Running postinstall script…"): bwrap( - ["chroot", "/work/postinst", "final"], - apivfs=state.root, - scripts=dict( - chroot=chroot_cmd( - state.root, - options=["--bind", state.config.postinst_script, "/work/postinst"], - network=state.config.with_network, - ), - ), - env=state.config.environment, + [state.config.postinst_script, "final"], + options=finalize_source_mounts(state.config) + finalize_writable_mounts(state.config), + scripts={"mkosi-chroot": chroot} | package_manager_scripts(state), + env=env | state.config.environment, stdin=sys.stdin, ) - shutil.rmtree(state.root / "work") - def run_finalize_script(state: MkosiState) -> None: if state.config.finalize_script is None: return + env = dict( + SCRIPT="/work/finalize", + SRCDIR=str(Path.cwd()), + OUTPUTDIR=str(state.staging), + BUILDROOT=str(state.root), + ) + + chroot = chroot_cmd( + state.root, + options=[ + "--bind", state.config.finalize_script, "/work/finalize", + "--bind", state.staging, "/work/out", + "--bind", Path.cwd(), "/work/src", + "--chdir", "/work/src", + "--setenv", "SRCDIR", "/work/src", + "--setenv", "OUTPUTDIR", "/work/out", + "--setenv", "BUILDROOT", "/", + ], + network=state.config.with_network, + ) + with complete_step("Running finalize script…"): - run([state.config.finalize_script], - env={**state.config.environment, "BUILDROOT": str(state.root), "OUTPUTDIR": str(state.staging)}) + bwrap( + [state.config.finalize_script], + options=finalize_source_mounts(state.config) + finalize_writable_mounts(state.config), + scripts={"mkosi-chroot": chroot} | package_manager_scripts(state), + env=env | state.config.environment, + stdin=sys.stdin, + ) def certificate_common_name(state: MkosiState, certificate: Path) -> str: @@ -2080,7 +2151,7 @@ def run_verb(args: MkosiArgs, presets: Sequence[MkosiConfig]) -> None: init_mount_namespace() # For extra safety when running as root, remount a bunch of stuff read-only. - for d in ("/usr", "/etc", "/opt", "/srv", "/boot", "/efi"): + for d in ("/usr", "/etc", "/opt", "/srv", "/boot", "/efi", "/media", "/mnt"): if Path(d).exists(): run(["mount", "--rbind", d, d, "--options", "ro"]) diff --git a/mkosi/config.py b/mkosi/config.py index 3e3ef4a5c..8ed67a9b0 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -1098,7 +1098,6 @@ class MkosiConfigParser: metavar="PATH", section="Content", parse=config_make_list_parser(delimiter=",", parse=make_source_target_paths_parser(absolute=False)), - default=[(Path("."), None)], help="Path for sources to build", ), MkosiConfigSetting( diff --git a/mkosi/distributions/gentoo.py b/mkosi/distributions/gentoo.py index b001bd841..31826d0d1 100644 --- a/mkosi/distributions/gentoo.py +++ b/mkosi/distributions/gentoo.py @@ -10,7 +10,7 @@ from pathlib import Path from mkosi.architecture import Architecture from mkosi.distributions import DistributionInstaller, PackageType from mkosi.log import ARG_DEBUG, complete_step, die -from mkosi.run import bwrap, chroot_cmd, run +from mkosi.run import apivfs_cmd, bwrap, chroot_cmd, run from mkosi.state import MkosiState from mkosi.tree import copy_tree, rmtree from mkosi.types import PathString @@ -19,7 +19,13 @@ from mkosi.util import sort_packages def invoke_emerge(state: MkosiState, packages: Sequence[str] = (), apivfs: bool = True) -> None: bwrap( - cmd=[ + cmd=apivfs_cmd(state.root) + [ + # We can't mount the stage 3 /usr using `options`, because bwrap isn't available in the stage 3 + # tarball which is required by apivfs_cmd(), so we have to mount /usr from the tarball later + # using another bwrap exec. + "bwrap", + "--dev-bind", "/", "/", + "--bind", state.cache_dir / "stage3/usr", "/usr", "emerge", "--buildpkg=y", "--usepkg=y", @@ -35,10 +41,8 @@ def invoke_emerge(state: MkosiState, packages: Sequence[str] = (), apivfs: bool f"--root={state.root}", *sort_packages(packages), ], - apivfs=state.root if apivfs else None, options=[ # TODO: Get rid of as many of these as possible. - "--bind", state.cache_dir / "stage3/usr", "/usr", "--bind", state.cache_dir / "stage3/etc", "/etc", "--bind", state.cache_dir / "stage3/var", "/var", "--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf", @@ -136,18 +140,14 @@ class GentooInstaller(DistributionInstaller): with (stage3 / "etc/portage/make.conf").open("a") as f: f.write(f"\nFEATURES=\"${{FEATURES}} {features}\"\n") - bwrap( - cmd=["chroot", "emerge-webrsync"], - apivfs=stage3, - scripts=dict( - chroot=chroot_cmd( - stage3, - options=["--bind", state.cache_dir / "repos", "/var/db/repos"], - network=True, - ), - ), + chroot = chroot_cmd( + stage3, + options=["--bind", state.cache_dir / "repos", "/var/db/repos"], + network=True, ) + bwrap(cmd=chroot + ["emerge-webrsync"]) + invoke_emerge(state, packages=["sys-apps/baselayout"], apivfs=False) @classmethod diff --git a/mkosi/installer/__init__.py b/mkosi/installer/__init__.py index 4e50e38e6..11d2523d6 100644 --- a/mkosi/installer/__init__.py +++ b/mkosi/installer/__init__.py @@ -3,8 +3,14 @@ import os from mkosi.config import ConfigFeature +from mkosi.installer.apt import apt_cmd +from mkosi.installer.dnf import dnf_cmd +from mkosi.installer.pacman import pacman_cmd +from mkosi.installer.zypper import zypper_cmd +from mkosi.run import apivfs_cmd from mkosi.state import MkosiState from mkosi.tree import rmtree +from mkosi.types import PathString def clean_package_manager_metadata(state: MkosiState) -> None: @@ -29,3 +35,23 @@ def clean_package_manager_metadata(state: MkosiState) -> None: else: for p in paths: rmtree(state.root / p) + + +def package_manager_scripts(state: MkosiState) -> dict[str, list[PathString]]: + return { + "pacman": apivfs_cmd(state.root) + pacman_cmd(state), + "zypper": apivfs_cmd(state.root) + zypper_cmd(state), + "dnf" : apivfs_cmd(state.root) + dnf_cmd(state), + } | { + command: apivfs_cmd(state.root) + apt_cmd(state, command) for command in ( + "apt", + "apt-cache", + "apt-cdrom", + "apt-config", + "apt-extracttemplates", + "apt-get", + "apt-key", + "apt-mark", + "apt-sortpkgs", + ) + } diff --git a/mkosi/installer/apt.py b/mkosi/installer/apt.py index fb6bb2c3b..f090bbc10 100644 --- a/mkosi/installer/apt.py +++ b/mkosi/installer/apt.py @@ -3,7 +3,7 @@ import shutil import textwrap from collections.abc import Sequence -from mkosi.run import bwrap +from mkosi.run import apivfs_cmd, bwrap from mkosi.state import MkosiState from mkosi.types import PathString from mkosi.util import sort_packages @@ -104,6 +104,5 @@ def invoke_apt( packages: Sequence[str] = (), apivfs: bool = True, ) -> None: - bwrap(apt_cmd(state, command) + [operation, *sort_packages(packages)], - apivfs=state.root if apivfs else None, - env=state.config.environment) + cmd = apivfs_cmd(state.root) if apivfs else [] + bwrap(cmd + apt_cmd(state, command) + [operation, *sort_packages(packages)], env=state.config.environment) diff --git a/mkosi/installer/dnf.py b/mkosi/installer/dnf.py index afaa946b9..393751588 100644 --- a/mkosi/installer/dnf.py +++ b/mkosi/installer/dnf.py @@ -6,7 +6,7 @@ from collections.abc import Iterable, Sequence from pathlib import Path from typing import NamedTuple -from mkosi.run import bwrap +from mkosi.run import apivfs_cmd, bwrap from mkosi.state import MkosiState from mkosi.tree import rmtree from mkosi.types import PathString @@ -114,9 +114,8 @@ def dnf_cmd(state: MkosiState) -> list[PathString]: def invoke_dnf(state: MkosiState, command: str, packages: Iterable[str], apivfs: bool = True) -> None: - bwrap(dnf_cmd(state) + [command, *sort_packages(packages)], - apivfs=state.root if apivfs else None, - env=state.config.environment) + cmd = apivfs_cmd(state.root) if apivfs else [] + bwrap(cmd + dnf_cmd(state) + [command, *sort_packages(packages)], env=state.config.environment) fixup_rpmdb_location(state.root) diff --git a/mkosi/installer/pacman.py b/mkosi/installer/pacman.py index adfc6cb92..dc4b49826 100644 --- a/mkosi/installer/pacman.py +++ b/mkosi/installer/pacman.py @@ -5,7 +5,7 @@ from pathlib import Path from mkosi.architecture import Architecture from mkosi.config import ConfigFeature -from mkosi.run import bwrap +from mkosi.run import apivfs_cmd, bwrap from mkosi.state import MkosiState from mkosi.types import PathString from mkosi.util import sort_packages @@ -110,6 +110,5 @@ def pacman_cmd(state: MkosiState) -> list[PathString]: def invoke_pacman(state: MkosiState, packages: Sequence[str], apivfs: bool = True) -> None: - bwrap(pacman_cmd(state) + ["-Sy", *sort_packages(packages)], - apivfs=state.root if apivfs else None, - env=state.config.environment) + cmd = apivfs_cmd(state.root) if apivfs else [] + bwrap(cmd + pacman_cmd(state) + ["-Sy", *sort_packages(packages)], env=state.config.environment) diff --git a/mkosi/installer/zypper.py b/mkosi/installer/zypper.py index c7c9ab584..86d62a4f1 100644 --- a/mkosi/installer/zypper.py +++ b/mkosi/installer/zypper.py @@ -3,7 +3,7 @@ import textwrap from collections.abc import Sequence from mkosi.installer.dnf import Repo, fixup_rpmdb_location -from mkosi.run import bwrap +from mkosi.run import apivfs_cmd, bwrap from mkosi.state import MkosiState from mkosi.types import PathString from mkosi.util import sort_packages @@ -68,8 +68,7 @@ def invoke_zypper( options: Sequence[str] = (), apivfs: bool = True, ) -> None: - bwrap(zypper_cmd(state) + [verb, *sort_packages(packages), *options], - apivfs=state.root if apivfs else None, - env=state.config.environment) + cmd = apivfs_cmd(state.root) if apivfs else [] + bwrap(cmd + zypper_cmd(state) + [verb, *sort_packages(packages), *options], env=state.config.environment) fixup_rpmdb_location(state.root) diff --git a/mkosi/run.py b/mkosi/run.py index 8f1eee7eb..801bd550c 100644 --- a/mkosi/run.py +++ b/mkosi/run.py @@ -252,7 +252,6 @@ def spawn( def bwrap( cmd: Sequence[PathString], *, - apivfs: Optional[Path] = None, options: Sequence[PathString] = (), log: bool = True, scripts: Mapping[str, Sequence[PathString]] = {}, @@ -262,6 +261,13 @@ def bwrap( cmdline: list[PathString] = [ "bwrap", "--dev-bind", "/", "/", + "--remount-ro", "/", + "--ro-bind", "/root", "/root", + "--ro-bind", "/home", "/home", + "--ro-bind", "/var", "/var", + "--ro-bind", "/run", "/run", + "--bind", "/var/tmp", "/var/tmp", + "--bind", Path.cwd(), Path.cwd(), "--chdir", Path.cwd(), "--unshare-pid", "--unshare-ipc", @@ -270,44 +276,11 @@ def bwrap( "--proc", "/proc", "--dev", "/dev", "--ro-bind", "/sys", "/sys", + "--tmpfs", "/tmp", *options, ] - if apivfs: - if not (apivfs / "etc/machine-id").exists(): - # Uninitialized means we want it to get initialized on first boot. - (apivfs / "etc/machine-id").write_text("uninitialized\n") - (apivfs / "etc/machine-id").chmod(0o0444) - - cmdline += [ - "--tmpfs", apivfs / "run", - "--tmpfs", apivfs / "tmp", - "--proc", apivfs / "proc", - "--dev", apivfs / "dev", - "--ro-bind", "/sys", apivfs / "sys", - ] - - # If passwd or a related file exists in the apivfs directory, bind mount it over the host files while - # we run the command, to make sure that the command we run uses user/group information from the - # apivfs directory instead of from the host. If the file doesn't exist yet, mount over /dev/null - # instead. - for f in ("passwd", "group", "shadow", "gshadow"): - p = apivfs / "etc" / f - if p.exists(): - cmdline += ["--bind", p, f"/etc/{f}"] - else: - cmdline += ["--bind", "/dev/null", f"/etc/{f}"] - - if apivfs: - chmod = f"chmod 1777 {apivfs / 'tmp'} {apivfs / 'var/tmp'} {apivfs / 'dev/shm'}" - # Make sure anything running in the apivfs directory thinks it's in a container. $container can't - # always be accessed so we write /run/host/container-manager as well which is always accessible. - container = f"mkdir {apivfs}/run/host && echo mkosi > {apivfs}/run/host/container-manager" - else: - chmod = container = ":" - - with tempfile.TemporaryDirectory(prefix="mkosi-var-tmp") as var_tmp,\ - tempfile.TemporaryDirectory(prefix="mkosi-scripts") as d: + with tempfile.TemporaryDirectory(prefix="mkosi-scripts") as d: for name, script in scripts.items(): # Make sure we don't end up in a recursive loop when we name a script after the binary it execs @@ -326,15 +299,7 @@ def bwrap( make_executable(Path(d) / name) cmdline += ["--setenv", "PATH", f"{d}:{os.environ['PATH']}"] - - if apivfs: - cmdline += [ - "--bind", var_tmp, apivfs / "var/tmp", - # Make sure /etc/machine-id is not overwritten by any package manager post install scripts. - "--ro-bind", apivfs / "etc/machine-id", apivfs / "etc/machine-id", - ] - - cmdline += ["sh", "-c", f"{chmod} && {container} && exec $0 \"$@\" || exit $?"] + cmdline += ["sh", "-c", "chmod 1777 /tmp /dev/shm && exec $0 \"$@\""] try: result = run([*cmdline, *cmd], env=env, log=False, stdin=stdin) @@ -344,19 +309,49 @@ def bwrap( if ARG_DEBUG_SHELL.get(): run([*cmdline, "sh"], stdin=sys.stdin, check=False, env=env, log=False) raise e - finally: - # Clean up some stuff that might get written by package manager post install scripts. - if apivfs: - for f in ("var/lib/systemd/random-seed", "var/lib/systemd/credential.secret", "etc/machine-info"): - # Using missing_ok=True still causes an OSError if the mount is read-only even if the - # file doesn't exist so do an explicit exists() check first. - if (apivfs / f).exists(): - (apivfs / f).unlink() return result -def chroot_cmd(root: Path, *, options: Sequence[PathString] = (), network: bool = False) -> Sequence[PathString]: +def apivfs_cmd(root: Path) -> list[PathString]: + cmdline: list[PathString] = [ + "bwrap", + "--dev-bind", "/", "/", + "--chdir", Path.cwd(), + "--tmpfs", root / "run", + "--tmpfs", root / "tmp", + "--bind", os.getenv("TMPDIR", "/var/tmp"), root / "var/tmp", + "--proc", root / "proc", + "--dev", root / "dev", + "--ro-bind", "/sys", root / "sys", + ] + + if (root / "etc/machine-id").exists(): + # Make sure /etc/machine-id is not overwritten by any package manager post install scripts. + cmdline += ["--ro-bind", root / "etc/machine-id", root / "etc/machine-id"] + + # If passwd or a related file exists in the apivfs directory, bind mount it over the host files while + # we run the command, to make sure that the command we run uses user/group information from the + # apivfs directory instead of from the host. If the file doesn't exist yet, mount over /dev/null + # instead. + for f in ("passwd", "group", "shadow", "gshadow"): + p = root / "etc" / f + if p.exists(): + cmdline += ["--bind", p, f"/etc/{f}"] + else: + cmdline += ["--bind", "/dev/null", f"/etc/{f}"] + + chmod = f"chmod 1777 {root / 'tmp'} {root / 'var/tmp'} {root / 'dev/shm'}" + # Make sure anything running in the root directory thinks it's in a container. $container can't always be + # accessed so we write /run/host/container-manager as well which is always accessible. + container = f"mkdir {root}/run/host && echo mkosi >{root}/run/host/container-manager" + + cmdline += ["sh", "-c", f"{chmod} && {container} && exec $0 \"$@\""] + + return cmdline + + +def chroot_cmd(root: Path, *, options: Sequence[PathString] = (), network: bool = False) -> list[PathString]: cmdline: list[PathString] = [ "bwrap", "--dev-bind", root, "/", @@ -382,7 +377,10 @@ def chroot_cmd(root: Path, *, options: Sequence[PathString] = (), network: bool else: cmdline += ["--unshare-net"] - return cmdline + # No exec here because we need to clean up the /work directory afterwards. + cmdline += ["sh", "-c", f"$0 \"$@\" && rm -rf {root / 'work'}"] + + return apivfs_cmd(root) + cmdline class MkosiAsyncioThread(threading.Thread):