Repository: osbuild/osbuild
Pull request #1526 (2024-01-04 15:08:00)

stages,util: add org.osbuild.selinux tests and small functional tweaks
This was originally started  because of work Ondrej did in https://github.com/ondrejbudai/osbuild/commit/dbf2ab49334929c186ee6035a0e1a9e992f431e7#diff-b009fdb01bdd4ed236a5c8df69d8b5d19360c70fe37fdde4beb5e222f723377fL36 when working on the containers-as-buildroots stage.

I started with the test and wanted to make file_contexts no longer required. But after further discussion/investigation it seems we don't need this particular change but having the tests is probably still useful and while writing it I noticed a small cleanup opportunity: instead of calling `setfiles` directly we can use the library function in `utils.selinux`. 

Easiest to read is probably commit by commit.


Patch:
diff --git a/docs/osbuild.1.rst b/docs/osbuild.1.rst
index 62b3ed63..82a2e0ad 100644
--- a/docs/osbuild.1.rst
+++ b/docs/osbuild.1.rst
@@ -57,6 +57,8 @@ is not listed here, **osbuild** will deny startup and exit with an error.
 --monitor-fd=NUM                file-descriptor to be used for the monitor
 --stage-timeout                 set the maximal time (in seconds) each stage is
                                 allowed to run
+--break, --break=ID             open debug shell when executing stages; accepts
+                                stage name or id (from --inspect) or * (for all)
 
 NB: If neither ``--output-directory`` nor ``--checkpoint`` is specified, no
 attempt to build the manifest will be made.
diff --git a/osbuild/buildroot.py b/osbuild/buildroot.py
index 5b47d70e..ddc60a0b 100644
--- a/osbuild/buildroot.py
+++ b/osbuild/buildroot.py
@@ -70,7 +70,7 @@ class ProcOverrides:
         self.overrides.add("cmdline")
 
 
-# pylint: disable=too-many-instance-attributes
+# pylint: disable=too-many-instance-attributes,too-many-branches
 class BuildRoot(contextlib.AbstractContextManager):
     """Build Root
 
@@ -177,7 +177,7 @@ class BuildRoot(contextlib.AbstractContextManager):
         if self._exitstack:
             self._exitstack.enter_context(api)
 
-    def run(self, argv, monitor, timeout=None, binds=None, readonly_binds=None, extra_env=None):
+    def run(self, argv, monitor, timeout=None, binds=None, readonly_binds=None, extra_env=None, debug_shell=False):
         """Runs a command in the buildroot.
 
         Takes the command and arguments, as well as bind mounts to mirror
@@ -289,6 +289,7 @@ class BuildRoot(contextlib.AbstractContextManager):
         cmd += self.build_capabilities_args()
 
         cmd += mounts
+        debug_shell_cmd = cmd + ["--", "/bin/bash"]  # used for debugging if requested
         cmd += ["--", runner]
         cmd += argv
 
@@ -304,6 +305,11 @@ class BuildRoot(contextlib.AbstractContextManager):
         if extra_env:
             env.update(extra_env)
 
+        # If the user requested it then break into a shell here
+        # for debugging.
+        if debug_shell:
+            subprocess.run(debug_shell_cmd, check=True)
+
         proc = subprocess.Popen(cmd,
                                 bufsize=0,
                                 env=env,
diff --git a/osbuild/main_cli.py b/osbuild/main_cli.py
index 81459470..19a72579 100644
--- a/osbuild/main_cli.py
+++ b/osbuild/main_cli.py
@@ -95,6 +95,9 @@ def parse_arguments(sys_argv):
     parser.add_argument("--version", action="version",
                         help="return the version of osbuild",
                         version="%(prog)s " + osbuild.__version__)
+    # nargs='?' const='*' means `--break` is equivalent to `--break=*`
+    parser.add_argument("--break", dest='debug_break', type=str, nargs='?', const='*',
+                        help="open debug shell when executing stage. Accepts stage name or id or * (for all)")
 
     return parser.parse_args(sys_argv[1:])
 
@@ -163,6 +166,7 @@ def osbuild_cli():
                 object_store.maximum_size = args.cache_max_size
 
             stage_timeout = args.stage_timeout
+            debug_break = args.debug_break
 
             pipelines = manifest.depsolve(object_store, exports)
 
@@ -173,6 +177,7 @@ def osbuild_cli():
                 pipelines,
                 monitor,
                 args.libdir,
+                debug_break,
                 stage_timeout=stage_timeout
             )
 
diff --git a/osbuild/pipeline.py b/osbuild/pipeline.py
index a450d791..af4c3944 100644
--- a/osbuild/pipeline.py
+++ b/osbuild/pipeline.py
@@ -142,7 +142,7 @@ class Stage:
         with open(location, "w", encoding="utf-8") as fp:
             json.dump(args, fp)
 
-    def run(self, tree, runner, build_tree, store, monitor, libdir, timeout=None):
+    def run(self, tree, runner, build_tree, store, monitor, libdir, debug_break="", timeout=None):
         with contextlib.ExitStack() as cm:
 
             build_root = buildroot.BuildRoot(build_tree, runner.path, libdir, store.tmp)
@@ -234,12 +234,15 @@ class Stage:
             if self.source_epoch is not None:
                 extra_env["SOURCE_DATE_EPOCH"] = str(self.source_epoch)
 
+            debug_shell = debug_break in ('*', self.name, self.id)
+
             r = build_root.run([f"/run/osbuild/bin/{self.name}"],
                                monitor,
                                timeout=timeout,
                                binds=binds,
                                readonly_binds=ro_binds,
-                               extra_env=extra_env)
+                               extra_env=extra_env,
+                               debug_shell=debug_shell)
 
         return BuildResult(self, r.returncode, r.output, api.error)
 
@@ -290,7 +293,7 @@ class Pipeline:
             self.assembler.base = stage.id
         return stage
 
-    def build_stages(self, object_store, monitor, libdir, stage_timeout=None):
+    def build_stages(self, object_store, monitor, libdir, debug_break="", stage_timeout=None):
         results = {"success": True}
 
         # If there are no stages, just return here
@@ -348,6 +351,7 @@ class Pipeline:
                           object_store,
                           monitor,
                           libdir,
+                          debug_break,
                           stage_timeout)
 
             monitor.result(r)
@@ -365,13 +369,14 @@ class Pipeline:
 
         return results
 
-    def run(self, store, monitor, libdir, stage_timeout=None):
+    def run(self, store, monitor, libdir, debug_break="", stage_timeout=None):
 
         monitor.begin(self)
 
         results = self.build_stages(store,
                                     monitor,
                                     libdir,
+                                    debug_break,
                                     stage_timeout)
 
         monitor.finish(results)
@@ -461,11 +466,11 @@ class Manifest:
 
         return list(map(lambda x: x.name, reversed(build.values())))
 
-    def build(self, store, pipelines, monitor, libdir, stage_timeout=None):
+    def build(self, store, pipelines, monitor, libdir, debug_break="", stage_timeout=None):
         results = {"success": True}
 
         for pl in map(self.get, pipelines):
-            res = pl.run(store, monitor, libdir, stage_timeout)
+            res = pl.run(store, monitor, libdir, debug_break, stage_timeout)
             results[pl.id] = res
             if not res["success"]:
                 results["success"] = False
diff --git a/sources/org.osbuild.skopeo b/sources/org.osbuild.skopeo
index f2d15292..ec7ce8ce 100755
--- a/sources/org.osbuild.skopeo
+++ b/sources/org.osbuild.skopeo
@@ -60,6 +60,10 @@ SCHEMA = """
                 "enum": ["docker", "containers-storage" ],
                 "description": "The containers transport from which to copy the container",
                 "default": "docker"
+              },
+              "storage-location": {
+                "type": "string",
+                "description": "The location of the local containers storage"
               }
             }
           }
@@ -103,13 +107,19 @@ class SkopeoSource(sources.SourceService):
         digest = image["digest"]
         tls_verify = image.get("tls-verify", True)
         transport = image.get("containers-transport", DOCKER_TRANSPORT)
+        location = image.get("storage-location", "")
 
         with tempfile.TemporaryDirectory(prefix="tmp-download-", dir=self.cache) as tmpdir:
             archive_dir = os.path.join(tmpdir, "container-archive")
             os.makedirs(archive_dir)
             os.chmod(archive_dir, 0o755)
 
-            reference = f"{imagename}@{digest}"
+            # Skopeo will read the default storage path from the
+            # /etc/containers/storage.conf unless storage-location
+            # is provided. See:
+            # https://github.com/containers/storage/blob/acbb93bb802702bc171b9987a47a9b713c280d38/types/options.go#L53
+            specifier = location if location == "" else f"[overlay@{location}]"
+            reference = f"{specifier}{imagename}@{digest}"
             source = self.get_source(transport, reference)
 
             # We use the dir format because it is the most powerful in terms of feature support and is the closest to a
diff --git a/sources/org.osbuild.skopeo-index b/sources/org.osbuild.skopeo-index
index 9af83f6e..b2ca3062 100755
--- a/sources/org.osbuild.skopeo-index
+++ b/sources/org.osbuild.skopeo-index
@@ -47,6 +47,10 @@ SCHEMA = """
                 "enum": ["docker", "containers-storage" ],
                 "description": "The containers transport from which to copy the container",
                 "default": "docker"
+              },
+              "storage-location": {
+                "type": "string",
+                "description": "The location of the local containers storage"
               }
             }
           }
@@ -87,13 +91,19 @@ class SkopeoIndexSource(sources.SourceService):
         imagename = image["name"]
         tls_verify = image.get("tls-verify", True)
         transport = image.get("containers-transport", DOCKER_TRANSPORT)
+        location = image.get("storage-location", "")
 
         with tempfile.TemporaryDirectory(prefix="tmp-download-", dir=self.cache) as tmpdir:
             archive_dir = os.path.join(tmpdir, "index")
             os.makedirs(archive_dir)
             os.chmod(archive_dir, 0o755)
 
-            reference = f"{imagename}@{digest}"
+            # Skopeo will read the default storage path from the
+            # /etc/containers/storage.conf unless storage-location
+            # is provided. See:
+            # https://github.com/containers/storage/blob/acbb93bb802702bc171b9987a47a9b713c280d38/types/options.go#L53
+            specifier = location if location == "" else f"[overlay@{location}]"
+            reference = f"{specifier}{imagename}@{digest}"
             source = self.get_source(transport, reference)
 
             destination = f"dir:{archive_dir}"
diff --git a/stages/org.osbuild.bootupd b/stages/org.osbuild.bootupd
new file mode 100755
index 00000000..224bd6d4
--- /dev/null
+++ b/stages/org.osbuild.bootupd
@@ -0,0 +1,149 @@
+#!/usr/bin/python3
+"""
+Install GRUB on both BIOS and UEFI systems,
+ensuring that your bootloader stays up-to-date.
+
+Bootupd supports updating GRUB and shim for
+UEFI firmware on x86_64 and aarch64,
+and GRUB for BIOS firmware on x86_64.
+The project is deployed in Fedora CoreOS and derivatives
+"""
+
+import contextlib
+import json
+import os
+import subprocess
+import sys
+
+import osbuild.api
+from osbuild.util import ostree
+
+SCHEMA_2 = r"""
+"devices": {
+  "type": "object",
+  "additionalProperties": true
+},
+"mounts": {
+  "type": "array"
+},
+"options": {
+  "additionalProperties": false,
+  "properties": {
+    "deployment": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["osname", "ref"],
+      "properties": {
+        "osname": {
+          "description": "Name of the stateroot to be used in the deployment",
+          "type": "string"
+        },
+        "ref": {
+          "description": "OStree ref to create and use for deployment",
+          "type": "string"
+        },
+        "serial": {
+          "description": "The deployment serial (usually '0')",
+          "type": "number",
+          "default": 0
+        }
+      }
+    },
+    "static-configs": {
+      "description": "Install the grub configs defined for Fedora CoreOS",
+      "type": "boolean"
+    },
+    "bios": {
+      "additionalProperties": false,
+      "type": "object",
+      "required": ["device"],
+      "properties": {
+        "device": {
+            "description": "Name of stage device to install GRUB for BIOS-based systems.",
+            "type": "string"
+        },
+        "partition": {
+            "description": "The partition on the stage device to install to, if installing to a partition",
+            "type": "number"
+        }
+      }
+    }
+  }
+}
+"""
+
+
+@contextlib.contextmanager
+def bind_mounts(sources, dest_root):
+    for src in sources:
+        dst = os.path.join(dest_root, src.lstrip("/"))
+        subprocess.run(["mount", "--rbind", src, dst], check=True)
+    try:
+        yield
+    finally:
+        for src in reversed(sources):
+            dst = os.path.join(dest_root, src.lstrip("/"))
+            subprocess.run(["umount", "--recursive", dst], check=False)
+
+
+def main(args, options):
+    deployment = options.get("deployment", None)
+    static_configs = options.get("static-configs", False)
+    bios = options.get("bios", {})
+    device = bios.get("device", "")
+    partition = bios.get("partition", None)
+
+    # Get the path where the filesystems are mounted
+    mounts = args["paths"]["mounts"]
+
+    # Get the deployment root. For non-OSTree this is simply
+    # the root location of the mount points. For OSTree systems
+    # we'll call ostree.deployment_path() helper to find it for us.
+    root = mounts
+    if deployment:
+        osname = deployment["osname"]
+        ref = deployment["ref"]
+        serial = deployment.get("serial", 0)
+        root = ostree.deployment_path(mounts, osname, ref, serial)
+
+    bootupd_args = []
+    if device:
+        # The value passed by the user is the name of the device
+        # as specified in the devices array (also passed in by the
+        # user). Let's map that name to the actual loopback device
+        # that now backs it.
+        target = args["devices"][device]["path"]
+        # If we are targeting a partition on the device rather than
+        # the whole device itself (i.e. MBR) then let's find that
+        # partition's device.
+        if partition:
+            cp = subprocess.run(["sfdisk", "--json", target],
+                                stdout=subprocess.PIPE,
+                                stderr=subprocess.STDOUT,
+                                encoding='utf8',
+                                check=True)
+            disk_table = json.loads(cp.stdout)["partitiontable"]
+            disk_parts = disk_table["partitions"]
+            index = partition - 1  # partition index starts at 0
+            target = disk_parts[index]["node"]
+        bootupd_args.append(f"--device={target}")
+    if static_configs:
+        bootupd_args.append("--with-static-configs")
+
+    # We want to run the bootupctl command from the target (i.e. we
+    # want to make sure the version used matches the target and not
+    # risk any inconsistencies with the build root). Let's set up
+    # and chroot to run the bootupctl command from the target.
+    with bind_mounts(['/dev', '/proc', '/sys', '/run', '/var', '/tmp'], root):
+        cmd = ['chroot', root, '/usr/bin/bootupctl', 'backend', 'install']
+        cmd.extend(bootupd_args)
+        cmd.append(mounts)
+        subprocess.run(cmd, check=True)
+
+    return 0
+
+
+if __name__ == '__main__':
+    _args = osbuild.api.arguments()
+    r = main(_args, _args["options"])
+    sys.exit(r)
diff --git a/stages/org.osbuild.container-deploy b/stages/org.osbuild.container-deploy
index c9546864..466c9b9b 100755
--- a/stages/org.osbuild.container-deploy
+++ b/stages/org.osbuild.container-deploy
@@ -5,6 +5,7 @@ Deploy a container.
 Buildhost commands used: podman skopeo
 """
 import contextlib
+import os
 import random
 import string
 import subprocess
@@ -54,6 +55,8 @@ def main(inputs, output):
     assert len(images) == 1
     image = list(images.values())[0]
 
+    # skopeo needs /var/tmp but the bwrap env is minimal and may not have it
+    os.makedirs("/var/tmp", mode=0o1777, exist_ok=True)
     # We cannot use a tmpdir as storage here because of
     # https://github.com/containers/storage/issues/1779 so instead
     # just pick a random suffix. This runs inside bwrap which gives a
diff --git a/stages/org.osbuild.grub2 b/stages/org.osbuild.grub2
index 93ccbd35..72e6e1a0 100755
--- a/stages/org.osbuild.grub2
+++ b/stages/org.osbuild.grub2
@@ -152,6 +152,11 @@ SCHEMA = """
          "examples": ["fedora"],
          "pattern": "^(.+)$"
       },
+      "efi_src_dir": {
+        "type": "string",
+         "description": "The source path to use for the EFI/ binaries when installing is set to True",
+         "default": "/boot/efi/EFI"
+      },
       "install": {
         "description": "Install EFI binaries and data from the build root",
         "type": "boolean",
@@ -347,10 +352,10 @@ def copy_font(tree):
     shutil.copy2("/usr/share/grub/unicode.pf2", f"{tree}/boot/grub2/fonts/")
 
 
-def copy_efi_data(tree, vendor):
+def copy_efi_data(tree, efi_src_dir, vendor):
     """Copy the EFI binaries & data into /boot/efi"""
     for d in ['BOOT', vendor]:
-        source = f"/boot/efi/EFI/{d}"
+        source = f"{efi_src_dir}/{d}"
         target = f"{tree}/boot/efi/EFI/{d}/"
         shutil.copytree(source, target,
                         symlinks=False)
@@ -573,13 +578,14 @@ def main(tree, options):
         # - grub.cfg: needs to be generated, either the canonical one
         #     or a shim one that redirects to the canonical one in
         #     /boot/grub2 in case of hybrid boot (see above)
+        efi_src_dir = uefi.get("efi_src_dir", "/boot/efi/EFI")
         vendor = uefi["vendor"]
         unified = uefi.get("unified", False)
 
         # EFI binaries and accompanying data can be installed from
         # the build root instead of using an rpm package
         if uefi.get('install', False):
-            copy_efi_data(tree, vendor)
+            copy_efi_data(tree, efi_src_dir, vendor)
 
         grubcfg = f"boot/efi/EFI/{vendor}/grub.cfg"
         if hybrid or unified:
diff --git a/stages/org.osbuild.selinux b/stages/org.osbuild.selinux
index 6c9c0360..83309b7c 100755
--- a/stages/org.osbuild.selinux
+++ b/stages/org.osbuild.selinux
@@ -22,7 +22,6 @@ may not match the tree's policy.
 
 import os
 import pathlib
-import subprocess
 import sys
 
 import osbuild.api
@@ -57,10 +56,9 @@ SCHEMA = """
 
 def main(tree, options):
     file_contexts = os.path.join(f"{tree}", options["file_contexts"])
-    labels = options.get("labels", {})
-
-    subprocess.run(["setfiles", "-F", "-r", f"{tree}", f"{file_contexts}", f"{tree}"], check=True)
+    selinux.setfiles(file_contexts, os.fspath(tree), "")
 
+    labels = options.get("labels", {})
     for path, label in labels.items():
         fullpath = os.path.join(tree, path.lstrip("/"))
         selinux.setfilecon(fullpath, label)
diff --git a/tools/osbuild-mpp b/tools/osbuild-mpp
index 018cfa01..6d5ebddd 100755
--- a/tools/osbuild-mpp
+++ b/tools/osbuild-mpp
@@ -1545,6 +1545,7 @@ class ManifestFileV2(ManifestFile):
                 return self.checksum
 
         def embed_data(ip, mpp):
+            self._process_format(mpp)
             uid = mpp["id"]
             path = mpp.get("path")
             url = mpp.get("url")


Test patch:
diff --git a/stages/test/test_bootupd.py b/stages/test/test_bootupd.py
new file mode 100644
index 00000000..9df845d3
--- /dev/null
+++ b/stages/test/test_bootupd.py
@@ -0,0 +1,118 @@
+#!/usr/bin/python3
+
+import os.path
+from unittest.mock import call, patch
+
+import pytest
+
+import osbuild.meta
+from osbuild.testutil.imports import import_module_from_path
+
+
+@pytest.mark.parametrize("test_data,expected_err", [
+    # bad
+    ({"deployment": "must-be-object"}, "'must-be-object' is not of type 'object'"),
+    ({"deployment": {"osname": "some-os"}}, "'ref' is a required property"),
+    ({"deployment": {"ref": "some-ref"}}, "'osname' is a required property"),
+    ({"deployment": {"osname": "some-os", "ref": "some-ref", "serial": "must-be-number"}},
+     "'must-be-number' is not of type 'number'"),
+    ({"random": "property"}, "Additional properties are not allowed"),
+    ({"bios": {}}, "'device' is a required property"),
+    ({"bios": "must-be-object"}, "'must-be-object' is not of type 'object'"),
+    # good
+    ({}, ""),
+    ({
+        "deployment":
+        {
+            "osname": "some-os",
+            "ref": "some-ref",
+            "serial": 1,
+        },
+        "static-configs": True,
+        "bios":
+        {
+            "device": "/dev/sda",
+        },
+    }, "")
+
+])
+def test_bootupd_schema_validation(test_data, expected_err):
+    name = "org.osbuild.bootupd"
+    root = os.path.join(os.path.dirname(__file__), "../..")
+    mod_info = osbuild.meta.ModuleInfo.load(root, "Stage", name)
+    schema = osbuild.meta.Schema(mod_info.get_schema(version="2"), name)
+
+    test_input = {
+        "type": "org.osbuild.bootupd",
+        "options": {
+        }
+    }
+    test_input["options"].update(test_data)
+    res = schema.validate(test_input)
+
+    if expected_err == "":
+        assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
+    else:
+        assert res.valid is False
+        assert len(res.errors) == 1, [e.as_dict() for e in res.errors]
+        err_msgs = [e.as_dict()["message"] for e in res.errors]
+        assert expected_err in err_msgs[0]
+
+
+@pytest.mark.parametrize("test_options,expected_bootupd_opts", [
+    ({}, []),
+    ({"bios": {"device": "/dev/sda"}}, ["--device=/mapped/dev/sda"]),
+    ({"static-configs": True}, ["--with-static-configs"]),
+])
+@patch("subprocess.run")
+def test_bootupd_defaults(mocked_run, test_options, expected_bootupd_opts):
+    stage_path = os.path.join(os.path.dirname(__file__), "../org.osbuild.bootupd")
+    stage = import_module_from_path("bootupd_stage", stage_path)
+
+    args = {
+        "paths": {
+            "mounts": "/run/osbuild/mounts",
+        },
+        "devices": {
+            "/dev/sda": {
+                "path": "/mapped/dev/sda",
+            },
+        },
+    }
+    options = test_options
+    with patch.object(stage, "bind_mounts") as mocked_bind_mounts:
+        stage.main(args, options)
+
+    assert len(mocked_bind_mounts.call_args_list) == 1
+    assert mocked_run.call_args_list == [call(["chroot",
+                                               "/run/osbuild/mounts",
+                                               "/usr/bin/bootupctl",
+                                               "backend",
+                                               "install"] + expected_bootupd_opts + ["/run/osbuild/mounts"],
+                                              check=True),
+                                         ]
+
+
+@patch("subprocess.run")
+def test_bootupd_bind_mounts(mocked_run):
+    stage_path = os.path.join(os.path.dirname(__file__), "../org.osbuild.bootupd")
+    stage = import_module_from_path("bootupd_stage", stage_path)
+
+    dst = "/run/osbuild/mounts/"
+    with stage.bind_mounts(['/dev', '/proc', '/sys', '/run', '/var', '/tmp'], dst):
+        pass
+    assert mocked_run.call_args_list == [
+        call(["mount", "--rbind", "/dev", "/run/osbuild/mounts/dev"], check=True),
+        call(["mount", "--rbind", "/proc", "/run/osbuild/mounts/proc"], check=True),
+        call(["mount", "--rbind", "/sys", "/run/osbuild/mounts/sys"], check=True),
+        call(["mount", "--rbind", "/run", "/run/osbuild/mounts/run"], check=True),
+        call(["mount", "--rbind", "/var", "/run/osbuild/mounts/var"], check=True),
+        call(["mount", "--rbind", "/tmp", "/run/osbuild/mounts/tmp"], check=True),
+        # and umount in reverse order
+        call(["umount", "--recursive", "/run/osbuild/mounts/tmp"], check=False),
+        call(["umount", "--recursive", "/run/osbuild/mounts/var"], check=False),
+        call(["umount", "--recursive", "/run/osbuild/mounts/run"], check=False),
+        call(["umount", "--recursive", "/run/osbuild/mounts/sys"], check=False),
+        call(["umount", "--recursive", "/run/osbuild/mounts/proc"], check=False),
+        call(["umount", "--recursive", "/run/osbuild/mounts/dev"], check=False),
+    ]
diff --git a/stages/test/test_container_deploy.py b/stages/test/test_container_deploy.py
index b1afae8e..6ccfed32 100644
--- a/stages/test/test_container_deploy.py
+++ b/stages/test/test_container_deploy.py
@@ -5,6 +5,7 @@ import os.path
 import random
 import string
 import subprocess
+from unittest.mock import call, patch
 
 import pytest
 
@@ -68,7 +69,10 @@ def test_container_deploy_integration(tmp_path):
     }
     output_dir = tmp_path / "output"
 
-    stage.main(inputs, output_dir)
+    with patch("os.makedirs", wraps=os.makedirs) as mocked_makedirs:
+        stage.main(inputs, output_dir)
 
     assert output_dir.exists()
     assert (output_dir / "file1").read_bytes() == b"file1 from final layer"
+
+    assert mocked_makedirs.call_args_list == [call("/var/tmp", mode=0o1777, exist_ok=True)]
diff --git a/stages/test/test_grub2.py b/stages/test/test_grub2.py
new file mode 100644
index 00000000..b95cbd46
--- /dev/null
+++ b/stages/test/test_grub2.py
@@ -0,0 +1,32 @@
+#!/usr/bin/python3
+
+import os.path
+
+from osbuild.testutil import make_fake_tree
+from osbuild.testutil.imports import import_module_from_path
+
+
+def test_grub2_copy_efi_data(tmp_path):
+    stage_path = os.path.join(os.path.dirname(__file__), "../org.osbuild.grub2")
+    stage = import_module_from_path("stage", stage_path)
+
+    fake_efi_src_dir = tmp_path / "fake-efi/EFI"
+    make_fake_tree(fake_efi_src_dir, {
+        "fedora/a.shim": "fake shim",
+        "BOOT/BOOTX64.EFI": "I'm not real",
+    })
+
+    test_options = {
+        "rootfs": {
+            "label": "my-rootfs",
+        },
+        "uefi": {
+            "install": True,
+            "efi_src_dir": fake_efi_src_dir,
+            "vendor": "fedora",
+        },
+    }
+    fake_tree = tmp_path / "tree"
+    stage.main(fake_tree, test_options)
+    assert (fake_tree / "boot/efi/EFI/fedora/a.shim").exists()
+    assert (fake_tree / "boot/efi/EFI/BOOT/BOOTX64.EFI").exists()
diff --git a/stages/test/test_selinux.py b/stages/test/test_selinux.py
new file mode 100644
index 00000000..507d2e99
--- /dev/null
+++ b/stages/test/test_selinux.py
@@ -0,0 +1,112 @@
+#!/usr/bin/python3
+
+import os.path
+from unittest.mock import call, patch
+
+import pytest
+
+import osbuild.meta
+import osbuild.testutil
+from osbuild.testutil.imports import import_module_from_path
+
+
+def schema_validation_selinux(test_data, implicit_file_contexts=True):
+    name = "org.osbuild.selinux"
+    root = os.path.join(os.path.dirname(__file__), "../..")
+    mod_info = osbuild.meta.ModuleInfo.load(root, "Stage", name)
+    schema = osbuild.meta.Schema(mod_info.get_schema(version="1"), name)
+
+    test_input = {
+        "name": "org.osbuild.selinux",
+        "options": {}
+    }
+    if implicit_file_contexts:
+        test_input["options"]["file_contexts"] = "some-context"
+
+    test_input["options"].update(test_data)
+    return schema.validate(test_input)
+
+
+@pytest.mark.parametrize("test_data,expected_err", [
+    # good
+    ({"labels": {"/usr/bin/cp": "system_u:object_r:install_exec_t:s0"}}, ""),
+    ({"force_autorelabel": True}, ""),
+    # bad
+    ({"file_contexts": 1234}, "1234 is not of type 'string'"),
+    ({"labels": "xxx"}, "'xxx' is not of type 'object'"),
+    ({"force_autorelabel": "foo"}, "'foo' is not of type 'boolean'"),
+])
+def test_schema_validation_selinux(test_data, expected_err):
+    res = schema_validation_selinux(test_data)
+    if expected_err == "":
+        assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
+    else:
+        assert res.valid is False
+        assert len(res.errors) == 1, [e.as_dict() for e in res.errors]
+        err_msgs = [e.as_dict()["message"] for e in res.errors]
+        assert expected_err in err_msgs[0]
+
+
+def test_schema_validation_selinux_file_context_required():
+    test_data = {}
+    res = schema_validation_selinux(test_data, implicit_file_contexts=False)
+    assert res.valid is False
+    assert len(res.errors) == 1, [e.as_dict() for e in res.errors]
+    err_msgs = [e.as_dict()["message"] for e in res.errors]
+    assert "'file_contexts' is a required property" in err_msgs[0]
+
+
+@patch("osbuild.util.selinux.setfiles")
+def test_selinux_file_contexts(mocked_setfiles, tmp_path):
+    stage_path = os.path.join(os.path.dirname(__file__), "../org.osbuild.selinux")
+    stage = import_module_from_path("stage", stage_path)
+
+    options = {
+        "file_contexts": "etc/selinux/thing",
+    }
+    stage.main(tmp_path, options)
+
+    assert len(mocked_setfiles.call_args_list) == 1
+    assert mocked_setfiles.call_args_list == [
+        call(f"{tmp_path}/etc/selinux/thing", os.fspath(tmp_path), "")
+    ]
+
+
+@patch("osbuild.util.selinux.setfilecon")
+@patch("osbuild.util.selinux.setfiles")
+def test_selinux_labels(mocked_setfiles, mocked_setfilecon, tmp_path):
+    stage_path = os.path.join(os.path.dirname(__file__), "../org.osbuild.selinux")
+    stage = import_module_from_path("stage", stage_path)
+
+    osbuild.testutil.make_fake_input_tree(tmp_path, {
+        "/usr/bin/bootc": "I'm only an imposter",
+    })
+
+    options = {
+        "file_contexts": "etc/selinux/thing",
+        "labels": {
+            "/tree/usr/bin/bootc": "system_u:object_r:install_exec_t:s0",
+        }
+    }
+    stage.main(tmp_path, options)
+
+    assert len(mocked_setfiles.call_args_list) == 1
+    assert len(mocked_setfilecon.call_args_list) == 1
+    assert mocked_setfilecon.call_args_list == [
+        call(f"{tmp_path}/tree/usr/bin/bootc", "system_u:object_r:install_exec_t:s0"),
+    ]
+
+
+@patch("osbuild.util.selinux.setfiles")
+def test_selinux_force_autorelabel(mocked_setfiles, tmp_path):  # pylint: disable=unused-argument
+    stage_path = os.path.join(os.path.dirname(__file__), "../org.osbuild.selinux")
+    stage = import_module_from_path("stage", stage_path)
+
+    for enable_autorelabel in [False, True]:
+        options = {
+            "file_contexts": "etc/selinux/thing",
+            "force_autorelabel": enable_autorelabel,
+        }
+        stage.main(tmp_path, options)
+
+        assert (tmp_path / ".autorelabel").exists() == enable_autorelabel
diff --git a/test/data/manifests/fedora-coreos-container.json b/test/data/manifests/fedora-coreos-container.json
index e253a61e..2dbb5617 100644
--- a/test/data/manifests/fedora-coreos-container.json
+++ b/test/data/manifests/fedora-coreos-container.json
@@ -504,8 +504,8 @@
               "type": "org.osbuild.containers",
               "origin": "org.osbuild.source",
               "references": {
-                "sha256:3bdf633fb6c027a01688aecf1d3f33bbecf8975b138c1861cb89ae20f3e3e7db": {
-                  "name": "registry.gitlab.com/redhat/services/products/image-builder/ci/images/fedora-coreos:stable"
+                "sha256:66d1012cb68ea1ec0c38a830499feadb2c9e015162a5acb818f65afb6f25d0e5": {
+                  "name": "registry.gitlab.com/redhat/services/products/image-builder/ci/images/fedora-coreos:testing"
                 }
               }
             }
@@ -529,25 +529,6 @@
               "ref": "ostree/1/1/0"
             }
           }
-        },
-        {
-          "type": "org.osbuild.grub2",
-          "options": {
-            "rootfs": {
-              "label": "root"
-            },
-            "bootfs": {
-              "label": "boot"
-            },
-            "uefi": {
-              "vendor": "fedora",
-              "install": true
-            },
-            "legacy": "i386-pc",
-            "write_defaults": false,
-            "greenboot": false,
-            "ignition": true
-          }
         }
       ]
     },
@@ -715,23 +696,49 @@
           ]
         },
         {
-          "type": "org.osbuild.grub2.inst",
+          "type": "org.osbuild.bootupd",
           "options": {
-            "platform": "i386-pc",
-            "filename": "disk.img",
-            "location": 2048,
-            "core": {
-              "type": "mkimage",
-              "partlabel": "gpt",
-              "filesystem": "ext4"
+            "bios": {
+              "device": "disk"
             },
-            "prefix": {
-              "type": "partition",
-              "partlabel": "gpt",
-              "number": 2,
-              "path": "/grub2"
+            "static-configs": true,
+            "deployment": {
+              "osname": "fedora-coreos",
+              "ref": "ostree/1/1/0"
             }
-          }
+          },
+          "devices": {
+            "disk": {
+              "type": "org.osbuild.loopback",
+              "options": {
+                "filename": "disk.img",
+                "partscan": true
+              }
+            }
+          },
+          "mounts": [
+            {
+              "name": "root",
+              "type": "org.osbuild.xfs",
+              "source": "disk",
+              "partition": 4,
+              "target": "/"
+            },
+            {
+              "name": "boot",
+              "type": "org.osbuild.ext4",
+              "source": "disk",
+              "partition": 3,
+              "target": "/boot"
+            },
+            {
+              "name": "efi",
+              "type": "org.osbuild.fat",
+              "source": "disk",
+              "partition": 2,
+              "target": "/boot/efi"
+            }
+          ]
         }
       ]
     },
@@ -902,6 +909,49 @@
               "target": "/boot/efi"
             }
           ]
+        },
+        {
+          "type": "org.osbuild.bootupd",
+          "options": {
+            "static-configs": true,
+            "deployment": {
+              "osname": "fedora-coreos",
+              "ref": "ostree/1/1/0"
+            }
+          },
+          "devices": {
+            "disk": {
+              "type": "org.osbuild.loopback",
+              "options": {
+                "filename": "disk.img",
+                "partscan": true,
+                "sector-size": 4096
+              }
+            }
+          },
+          "mounts": [
+            {
+              "name": "root",
+              "type": "org.osbuild.xfs",
+              "source": "disk",
+              "partition": 4,
+              "target": "/"
+            },
+            {
+              "name": "boot",
+              "type": "org.osbuild.ext4",
+              "source": "disk",
+              "partition": 3,
+              "target": "/boot"
+            },
+            {
+              "name": "efi",
+              "type": "org.osbuild.fat",
+              "source": "disk",
+              "partition": 2,
+              "target": "/boot/efi"
+            }
+          ]
         }
       ]
     },
@@ -1562,10 +1612,10 @@
     },
     "org.osbuild.skopeo": {
       "items": {
-        "sha256:3bdf633fb6c027a01688aecf1d3f33bbecf8975b138c1861cb89ae20f3e3e7db": {
+        "sha256:66d1012cb68ea1ec0c38a830499feadb2c9e015162a5acb818f65afb6f25d0e5": {
           "image": {
             "name": "registry.gitlab.com/redhat/services/products/image-builder/ci/images/fedora-coreos",
-            "digest": "sha256:f0ebfc8bb813e45225a93e76780c44439fac11a24ba8fb83001ec96a61ebbb12"
+            "digest": "sha256:2ba8999ef74d45f39c601c20783d61a1c632636eb3e8ead9c020e29bfbbae8bf"
           }
         }
       }
diff --git a/test/data/manifests/fedora-coreos-container.mpp.yaml b/test/data/manifests/fedora-coreos-container.mpp.yaml
index aefda0d9..27983e54 100644
--- a/test/data/manifests/fedora-coreos-container.mpp.yaml
+++ b/test/data/manifests/fedora-coreos-container.mpp.yaml
@@ -112,7 +112,7 @@ pipelines:
             mpp-resolve-images:
               images:
                 - source: registry.gitlab.com/redhat/services/products/image-builder/ci/images/fedora-coreos
-                  tag: stable
+                  tag: testing
       - type: org.osbuild.ostree.aleph
         options:
           coreos_compat: true
@@ -124,19 +124,6 @@ pipelines:
           deployment:
             osname: fedora-coreos
             ref: ostree/1/1/0
-      - type: org.osbuild.grub2
-        options:
-          rootfs:
-            label: root
-          bootfs:
-            label: boot
-          uefi:
-            vendor: fedora
-            install: true
-          legacy: i386-pc
-          write_defaults: false
-          greenboot: false
-          ignition: true
   - name: raw-image
     build: name:build
     stages:
@@ -231,23 +218,39 @@ pipelines:
             partition:
               mpp-format-int: '{image.layout[''EFI-SYSTEM''].partnum}'
             target: /boot/efi
-      - type: org.osbuild.grub2.inst
+      - type: org.osbuild.bootupd
         options:
-          platform: i386-pc
-          filename: disk.img
-          location:
-            mpp-format-int: '{image.layout[''BIOS-BOOT''].start}'
-          core:
-            type: mkimage
-            partlabel: gpt
-            filesystem: ext4
-          prefix:
-            type: partition
-            partlabel:
-              mpp-format-string: '{image.layout.label}'
-            number:
-              mpp-format-int: '{image.layout[''boot''].index}'
-            path: /grub2
+          bios:
+            device: disk
+          static-configs: true
+          deployment:
+            osname: fedora-coreos
+            ref: ostree/1/1/0
+        devices:
+          disk:
+            type: org.osbuild.loopback
+            options:
+              filename: disk.img
+              partscan: true
+        mounts:
+          - name: root
+            type: org.osbuild.xfs
+            source: disk
+            partition:
+              mpp-format-int: '{image.layout[''root''].partnum}'
+            target: /
+          - name: boot
+            type: org.osbuild.ext4
+            source: disk
+            partition:
+              mpp-format-int: '{image.layout[''boot''].partnum}'
+            target: /boot
+          - name: efi
+            type: org.osbuild.fat
+            source: disk
+            partition:
+              mpp-format-int: '{image.layout[''EFI-SYSTEM''].partnum}'
+            target: /boot/efi
   - name: raw-4k-image
     build: name:build
     stages:
@@ -352,6 +355,39 @@ pipelines:
             partition:
               mpp-format-int: '{image4k.layout[''EFI-SYSTEM''].partnum}'
             target: /boot/efi
+      - type: org.osbuild.bootupd
+        options:
+          static-configs: true
+          deployment:
+            osname: fedora-coreos
+            ref: ostree/1/1/0
+        devices:
+          disk:
+            type: org.osbuild.loopback
+            options:
+              filename: disk.img
+              partscan: true
+              sector-size:
+                  mpp-format-int: "{four_k_sector_size}"
+        mounts:
+          - name: root
+            type: org.osbuild.xfs
+            source: disk
+            partition:
+              mpp-format-int: '{image4k.layout[''root''].partnum}'
+            target: /
+          - name: boot
+            type: org.osbuild.ext4
+            source: disk
+            partition:
+              mpp-format-int: '{image4k.layout[''boot''].partnum}'
+            target: /boot
+          - name: efi
+            type: org.osbuild.fat
+            source: disk
+            partition:
+              mpp-format-int: '{image4k.layout[''EFI-SYSTEM''].partnum}'
+            target: /boot/efi
   - name: raw-metal-image
     build: name:build
     stages:
diff --git a/test/mod/test_util_selinux.py b/test/mod/test_util_selinux.py
index c8524aa6..e147bcf8 100644
--- a/test/mod/test_util_selinux.py
+++ b/test/mod/test_util_selinux.py
@@ -4,6 +4,7 @@
 
 import errno
 import io
+import os
 from unittest import mock
 
 from osbuild.util import selinux
@@ -58,3 +59,18 @@ def test_setfilecon():
             setxattr.side_effect = raise_error
 
             selinux.setfilecon("path", "context")
+
+
+@mock.patch("subprocess.run")
+def test_selinux_setfiles(mocked_run, tmp_path):
+    selinux.setfiles("/etc/selinux/thing", os.fspath(tmp_path), "/", "/boot")
+
+    assert len(mocked_run.call_args_list) == 2
+    assert mocked_run.call_args_list == [
+        mock.call(
+            ["setfiles", "-F", "-r", os.fspath(tmp_path),
+             "/etc/selinux/thing", os.fspath(tmp_path) + "/"], check=True),
+        mock.call(
+            ["setfiles", "-F", "-r", os.fspath(tmp_path),
+             "/etc/selinux/thing", os.fspath(tmp_path) + "/boot"], check=True),
+    ]
diff --git a/test/run/test_exports.py b/test/run/test_exports.py
new file mode 100644
index 00000000..12b4cfe7
--- /dev/null
+++ b/test/run/test_exports.py
@@ -0,0 +1,89 @@
+import contextlib
+import json
+import os
+import pathlib
+import shutil
+import subprocess
+
+import pytest
+
+from .. import test
+
+
+@pytest.fixture(name="jsondata", scope="module")
+def jsondata_fixture():
+    return json.dumps({
+        "version": "2",
+        "pipelines": [
+            {
+                "name": "image",
+                "stages": [
+                    {
+                        "type": "org.osbuild.truncate",
+                        "options": {
+                            "filename": "foo.img",
+                            "size": "10",
+                        },
+                    },
+                    {
+                        # cannot use org.osbuild.chown as it needs the chown
+                        # binary in the stage
+                        "type": "org.osbuild.testing.injectpy",
+                        "options": {
+                            "code": [
+                                'import os',
+                                'os.chown(f"{tree}/foo.img", 1000, 1000)',
+                            ],
+                        },
+                    },
+                ]
+            }
+        ]
+    })
+
+
+@pytest.fixture(name="osb", scope="module")
+def osbuild_fixture():
+    with test.OSBuild() as osb:
+        yield osb
+
+
+@pytest.fixture(name="testing_libdir", scope="module")
+def testing_libdir_fixture(tmpdir_factory):
+    tests_path = pathlib.Path(__file__).parent.parent
+    project_path = tests_path.parent
+    testing_libdir_path = tests_path / "stages"
+    fake_libdir_path = tmpdir_factory.mktemp("fake-libdir")
+    # there must be an empty "osbild" dir for a "os.listdir(self._libdir)"
+    # in buildroot.py
+    (fake_libdir_path / "osbuild").mkdir()
+    # construct minimal viable libdir from current checkout
+    for d in ["stages", "runners", ]:
+        subprocess.run(
+            ["cp", "-a", os.fspath(project_path / d), f"{fake_libdir_path}"],
+            check=True)
+    # now inject testing stages
+    for p in testing_libdir_path.glob("org.osbuild.testing.*"):
+        shutil.copy2(p, fake_libdir_path / "stages")
+    yield fake_libdir_path
+
+
+@pytest.mark.skipif(os.getuid() != 0, reason="root-only")
+def test_exports_normal(osb, tmp_path, jsondata, testing_libdir):  # pylint: disable=unused-argument
+    osb.compile(jsondata, output_dir=tmp_path, exports=["image"], libdir=testing_libdir)
+    expected_export = tmp_path / "image/foo.img"
+    assert expected_export.exists()
+    assert expected_export.stat().st_uid == 1000
+
+
+@pytest.mark.skipif(os.getuid() != 0, reason="root-only")
+def test_exports_with_force_no_preserve_owner(osb, tmp_path, jsondata, testing_libdir):
+    with contextlib.ExitStack() as cm:
+        k = "OSBUILD_EXPORT_FORCE_NO_PRESERVE_OWNER"
+        os.environ[k] = "1"
+        cm.callback(os.unsetenv, k)
+
+        osb.compile(jsondata, output_dir=tmp_path, exports=["image"], libdir=testing_libdir)
+        expected_export = tmp_path / "image/foo.img"
+        assert expected_export.exists()
+        assert expected_export.stat().st_uid == 0
diff --git a/test/stages/README.md b/test/stages/README.md
new file mode 100644
index 00000000..1000bff9
--- /dev/null
+++ b/test/stages/README.md
@@ -0,0 +1,5 @@
+This directory contains stages that can only be used during testing and
+will be dynamically added to the library directory.
+
+See `test/run/test_exports.py` for an example usage of the `pyinject`
+stage.
diff --git a/test/stages/org.osbuild.testing.injectpy b/test/stages/org.osbuild.testing.injectpy
new file mode 100755
index 00000000..b842a8e7
--- /dev/null
+++ b/test/stages/org.osbuild.testing.injectpy
@@ -0,0 +1,27 @@
+#!/usr/bin/python3
+"""
+Inject arbitrary python code.
+
+ONLY USE FOR TESTING
+"""
+
+import sys
+
+import osbuild.api
+
+SCHEMA_2 = """
+"options": {
+  "additionalProperties": true
+}
+"""
+
+
+def main(tree, options):  # pylint: disable=unused-argument
+    script = "\n".join(options.get("code", []))
+    exec(script)  # pylint: disable=exec-used
+
+
+if __name__ == '__main__':
+    args = osbuild.api.arguments()
+    r = main(args["tree"], args.get("options", {}))
+    sys.exit(r)
diff --git a/test/test.py b/test/test.py
index b9602e58..018bc4e0 100644
--- a/test/test.py
+++ b/test/test.py
@@ -347,7 +347,7 @@ class OSBuild(contextlib.AbstractContextManager):
             print(log)
         print("-- END ---------------------------------")
 
-    def compile(self, data_stdin, output_dir=None, checkpoints=None, check=False, exports=None):
+    def compile(self, data_stdin, output_dir=None, checkpoints=None, check=False, exports=None, libdir="."):
         """Compile an Artifact
 
         This takes a manifest as `data_stdin`, executes the pipeline, and
@@ -377,7 +377,7 @@ class OSBuild(contextlib.AbstractContextManager):
             cmd_args = [sys.executable, "-m", "osbuild"]
 
             cmd_args += ["--json"]
-            cmd_args += ["--libdir", "."]
+            cmd_args += ["--libdir", libdir]
             cmd_args += ["--output-directory", output_dir]
             cmd_args += ["--store", self._cachedir]