From 3904661440d8fb5ca2e2274a2b4a38320ad83e2e Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Wed, 26 Aug 2026 11:33:34 +0200 Subject: [PATCH 1/2] test: make boot mode work in unprivileged containers In a mount namespace owned by a non-initial user namespace, the kernel only allows mounting a fresh proc or sysfs if the namespace already contains a fully visible mount of the same filesystem type (mount_too_revealing()). systemd-nspawn's pivoted root contains no such mount, so booting the test container inside an unprivileged container (e.g. a containerized CI runner) fails with EPERM when mounting /proc. When running in a non-initial user namespace, satisfy the check by binding a read-only instance of each into the container under /work. Skipping the binds on regular privileged runs keeps the container isolated from the host's proc and sysfs. Binding non-recursively keeps foreign submounts (e.g. lxcfs overmounts) out, which would spoil full visibility where they end up locked, and which read-only would not cover. Signed-off-by: Paul Meyer --- .../integration-test-wrapper.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/test/integration-tests/integration-test-wrapper.py b/test/integration-tests/integration-test-wrapper.py index ed8a8f2607..19dcf2bc28 100755 --- a/test/integration-tests/integration-test-wrapper.py +++ b/test/integration-tests/integration-test-wrapper.py @@ -365,6 +365,15 @@ def process_coverage(args: argparse.Namespace, summary: Summary, name: str, jour print(f'Wrote coverage report for {name} to {output}', file=sys.stderr) +def in_userns() -> bool: + # The initial user namespace has a well-known inode number (USER_NS_INIT_INO, linux/nsfs.h). + # Without kernel support for user namespaces, ours can only be the initial one. + try: + return os.stat('/proc/self/ns/user').st_ino != 0xEFFFFFFD + except OSError: + return False + + def coco_host_type() -> Optional[str]: try: if Path('/sys/module/kvm_intel/parameters/tdx').read_text().strip() == 'Y': @@ -706,7 +715,26 @@ def main() -> None: *(['--runtime-build-sources=no', '--register=no'] if not sys.stdin.isatty() else []), 'vm' if vm else 'boot', *( - ['--', '--capability=CAP_BPF', f'--suppress-sync={"yes" if args.suppress_sync else "no"}', *vm_images_args, *coco_boot_args] + [ + '--', + '--capability=CAP_BPF', + f'--suppress-sync={"yes" if args.suppress_sync else "no"}', + # In a non-initial user namespace the kernel only allows mounting a fresh proc or + # sysfs if the mount namespace already contains a fully visible mount of the same + # filesystem type (mount_too_revealing()), which nspawn's pivoted root doesn't. + # Satisfy the check with read-only binds, but only when actually confined, as + # they expose the outer proc and sysfs to the container. Bind non-recursively: + # carried-along submounts (e.g. lxcfs overmounts of container runtimes) would + # spoil full visibility wherever they end up locked, and --bind-ro wouldn't make + # them read-only either. + *( + ['--bind-ro=/proc:/work/proc:norbind', '--bind-ro=/sys:/work/sys:norbind'] + if in_userns() + else [] + ), + *vm_images_args, + *coco_boot_args, + ] if not vm else [] ), From 5d8191da483b08e6f1702f912abc901a415669ab Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Wed, 26 Aug 2026 13:08:14 +0200 Subject: [PATCH 2/2] test: take extra mkosi options from TEST_MKOSI_ARGS Add a TEST_MKOSI_ARGS environment variable whose contents are appended to the mkosi invocation launching the test image, without affecting the image build. This lets the environment adapt how tests are run, e.g. pass --format=directory --output-dir=... to boot the test container from a directory format output in environments that cannot dissect disk images, such as unprivileged containers, where loop devices and udev are not available. Signed-off-by: Paul Meyer --- test/integration-tests/README.md | 9 +++++++++ test/integration-tests/integration-test-wrapper.py | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/test/integration-tests/README.md b/test/integration-tests/README.md index 229e802632..e5f7c76713 100644 --- a/test/integration-tests/README.md +++ b/test/integration-tests/README.md @@ -120,6 +120,15 @@ rerun the test. `TEST_NO_KVM=1`: Disable qemu KVM auto-detection (may be necessary when you're trying to run the *vanilla* qemu and have both qemu and qemu-kvm installed) +`TEST_MKOSI_ARGS=options`: Extra options (split like a shell command line) +passed to the `mkosi` invocation that launches the test image, without +affecting the image build. Only the `--option=value` syntax is accepted. For example, +`TEST_MKOSI_ARGS="--format=directory --output-dir=build/mkosi.output.dir"` +boots the test container from a directory format output (which has to be built +beforehand, into a separate output directory), allowing boot mode tests to run +in environments that can't dissect disk images, such as unprivileged +containers, where loop devices and udev are not available. + `TEST_MATCH_SUBTEST=subtest`: If the test makes use of `run_subtests` use this variable to provide a POSIX extended regex to run only subtests matching the expression. diff --git a/test/integration-tests/integration-test-wrapper.py b/test/integration-tests/integration-test-wrapper.py index 19dcf2bc28..1324b6ccd3 100755 --- a/test/integration-tests/integration-test-wrapper.py +++ b/test/integration-tests/integration-test-wrapper.py @@ -670,6 +670,12 @@ def main() -> None: ''' ) + # Extra mkosi options for this invocation only. Each has to use the '--option=value' syntax. + test_mkosi_args = shlex.split(os.getenv('TEST_MKOSI_ARGS', '')) + for arg in test_mkosi_args: + if not re.fullmatch('--[^=]+=.*', arg): + sys.exit(f"TEST_MKOSI_ARGS must contain only '--option=value' mkosi options, got: {arg}") + cmd = [ args.mkosi, '--directory', os.fspath(args.mkosi_dir), @@ -713,6 +719,7 @@ def main() -> None: ), '--credential', f"journal.storage={'persistent' if sys.stdin.isatty() else args.storage}", *(['--runtime-build-sources=no', '--register=no'] if not sys.stdin.isatty() else []), + *test_mkosi_args, 'vm' if vm else 'boot', *( [