diff --git a/test/integration-tests/README.md b/test/integration-tests/README.md index e0a345613c..229e802632 100644 --- a/test/integration-tests/README.md +++ b/test/integration-tests/README.md @@ -141,6 +141,24 @@ journal file will be saved only when the test is failed. Defaults to `fail`. progress and only move the journal to its final location in the build directory (`$BUILD_DIR/test/journal`) when the test is finished. +## Confidential computing (coco) tests + +Tests that exercise systemd inside a confidential VM (currently `TEST-94-COCO`) +launch a real Intel TDX or AMD SEV-SNP guest with `systemd-vmspawn --coco=`. They +only run on a coco-capable host and skip everywhere else. A confidential VM cannot +be nested, so these tests run in boot (nspawn) mode on a bare-metal coco host, +as root. The host must have coco enabled in firmware and in the kernel (for SEV-SNP +e.g. `kvm_amd.sev_snp=1`, with `/dev/sev` accessible). On a host without the requested +technology, or when running in `mkosi vm` mode, the tests are skipped. + +``` +meson test + └─ integration-test-wrapper.py + └─ mkosi boot ──► systemd-nspawn container + └─ TEST-94-COCO.sh + └─ systemd-vmspawn --coco= ──► confidential VM +``` + ## Running the integration tests without building systemd from source If you want to run the integration tests against prebuilt systemd packages, diff --git a/test/integration-tests/TEST-94-COCO/meson.build b/test/integration-tests/TEST-94-COCO/meson.build new file mode 100644 index 0000000000..8d06375a21 --- /dev/null +++ b/test/integration-tests/TEST-94-COCO/meson.build @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +integration_tests += [ + integration_test_template + { + 'name' : fs.name(meson.current_source_dir()), + 'coco' : 'any', + # Tests need the image.raw build artifact to boot a full guest. + 'vm-images' : true, + }, +] diff --git a/test/integration-tests/integration-test-wrapper.py b/test/integration-tests/integration-test-wrapper.py index bdebe9c70e..ed8a8f2607 100755 --- a/test/integration-tests/integration-test-wrapper.py +++ b/test/integration-tests/integration-test-wrapper.py @@ -365,6 +365,35 @@ def process_coverage(args: argparse.Namespace, summary: Summary, name: str, jour print(f'Wrote coverage report for {name} to {output}', file=sys.stderr) +def coco_host_type() -> Optional[str]: + try: + if Path('/sys/module/kvm_intel/parameters/tdx').read_text().strip() == 'Y': + return 'tdx' + except OSError: + pass + try: + if ( + Path('/sys/module/kvm_amd/parameters/sev_snp').read_text().strip() == 'Y' + and Path('/dev/sev').exists() + ): + return 'sev-snp' + except OSError: + pass + return None + + +def coco_nspawn_args(coco_type: str) -> list[str]: + # systemd-nspawn args (appended after '--' in boot mode) that expose the devices vmspawn needs to + # launch a confidential guest from inside the boot-mode container. + binds = ['--bind=/dev/kvm', '--bind=/dev/vhost-vsock'] + if coco_type == 'sev-snp': + binds += ['--bind=/dev/sev'] + # TDX guests obtain quotes via the host's Quote Generation Service; forward its socket if present. + if coco_type == 'tdx' and Path('/run/tdx-qgs/qgs.socket').exists(): + binds += ['--bind=/run/tdx-qgs/qgs.socket'] + return binds + + def statfs(path: Path) -> str: return subprocess.run( ['stat', '--file-system', os.fspath(path), '--format=%T'], @@ -407,6 +436,7 @@ def main() -> None: parser.add_argument('--rtc', action=argparse.BooleanOptionalAction) parser.add_argument('--tpm', action=argparse.BooleanOptionalAction) parser.add_argument('--vm-images', action=argparse.BooleanOptionalAction) + parser.add_argument('--coco', default=None, choices=('tdx', 'sev-snp', 'any')) parser.add_argument('--skip', action=argparse.BooleanOptionalAction) parser.add_argument('--suppress-sync', action=argparse.BooleanOptionalAction, default=False) parser.add_argument('mkosi_args', nargs='*') @@ -597,6 +627,40 @@ def main() -> None: file=sys.stderr, ) + # Coco needs to launch a real (L1) confidential guest, which requires the boot-mode nspawn on + # a coco-capable bare-metal host. Nested QEMU VM (vm mode) won't work. + coco_boot_args: list[str] = [] + if args.coco: + if vm: + print( + f'{args.name} needs bare-metal boot mode (root, no nesting) for confidential VMs, skipping', + file=sys.stderr, + ) + exit(77) + host = coco_host_type() + if host is None or (args.coco != 'any' and host != args.coco): + print( + f'Host does not provide coco={args.coco} (detected: {host}), skipping {args.name}', + file=sys.stderr, + ) + exit(77) + if not Path('/dev/vhost-vsock').exists(): + print( + f'{args.name} needs /dev/vhost-vsock (load the vhost_vsock module) for the guest ' + f'notify channel, skipping', + file=sys.stderr, + ) + exit(77) + coco_boot_args = coco_nspawn_args(host) + # /sys/module/*/parameters isn't reliably readable inside the nspawn container, so hand the + # resolved coco type to the test via the environment instead of making it re-detect. + dropin += textwrap.dedent( + f''' + [Service] + Environment=COCO_TYPE={host} + ''' + ) + cmd = [ args.mkosi, '--directory', os.fspath(args.mkosi_dir), @@ -642,7 +706,7 @@ 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] + ['--', '--capability=CAP_BPF', f'--suppress-sync={"yes" if args.suppress_sync else "no"}', *vm_images_args, *coco_boot_args] if not vm else [] ), diff --git a/test/integration-tests/meson.build b/test/integration-tests/meson.build index 50f8228e02..dbfc5d5a11 100644 --- a/test/integration-tests/meson.build +++ b/test/integration-tests/meson.build @@ -34,6 +34,7 @@ integration_test_template = { # Bind the mkosi output (built images) read-only into the boot-mode container at /work/vm-images, for # tests that boot a nested VM from a full image. 'vm-images' : false, + 'coco' : '', 'suppress-sync' : true, } @@ -109,6 +110,7 @@ foreach dirname : [ 'TEST-91-LIVEUPDATE', 'TEST-92-TPM2-SWTPM', 'TEST-93-CLONESETUP', + 'TEST-94-COCO', ] subdir(dirname) endforeach @@ -142,6 +144,10 @@ foreach integration_test : integration_tests integration_test_args += ['--vm-images'] endif + if integration_test['coco'] != '' + integration_test_args += ['--coco', integration_test['coco']] + endif + if not integration_test['enabled'] integration_test_args += ['--skip'] endif diff --git a/test/units/TEST-94-COCO.sh b/test/units/TEST-94-COCO.sh new file mode 100755 index 0000000000..fc9750b635 --- /dev/null +++ b/test/units/TEST-94-COCO.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# Launch a confidential VM with systemd-vmspawn --coco= and verify the guest reports the expected +# confidential-virtualization type. Runs only on coco-capable hardware: the harness --coco=any flag gates +# the run (skipped otherwise), binds /dev/kvm + the coco device(s) into this boot-mode container, and +# passes the resolved technology via $COCO_TYPE. +# +# The confidential guest boots the same mkosi test image. A one-shot unit injected into the guest checks +# systemd-detect-virt --cvm and reports the verdict via systemd's exit-status notification over vsock +# (SuccessAction=exit): the guest PID1 sends EXIT_STATUS to vmspawn's vmm notify socket and vmspawn exits +# with it. +set -eux +set -o pipefail + +# shellcheck source=test/units/util.sh +. "$(dirname "$0")"/util.sh + +if [[ -v ASAN_OPTIONS ]]; then + echo "vmspawn launches QEMU which doesn't work under ASan, skipping" | tee --append /skipped + exit 77 +fi + +if ! command -v systemd-vmspawn >/dev/null 2>&1; then + echo "systemd-vmspawn not found, skipping" | tee --append /skipped + exit 77 +fi + +if ! find_qemu_binary; then + echo "QEMU not found, skipping" | tee --append /skipped + exit 77 +fi + +# The integration-test-wrapper resolves the host's coco technology on the +# bare-metal host (where /sys/module is readable) and passes it via $COCO_TYPE. +if [[ -z "${COCO_TYPE:-}" ]]; then + echo "COCO_TYPE not set (run via the integration harness with --coco), skipping" | tee --append /skipped + exit 77 +fi +echo "Host coco type: $COCO_TYPE" + +# The harness binds the guest image read-only at /work/vm-images (the 'vm-images' test option). +IMAGE_DIR=/work/vm-images +if [[ ! -f "$IMAGE_DIR/image.raw" || ! -f "$IMAGE_DIR/image.vmlinuz" || ! -f "$IMAGE_DIR/image.initrd" ]]; then + echo "guest image not found in $IMAGE_DIR, skipping" | tee --append /skipped + exit 77 +fi +echo "Guest image directory: $IMAGE_DIR" + +MACHINE="coco-guest-$$" +WORKDIR="$(mktemp -d)" +at_exit() { + set +e + machinectl terminate "$MACHINE" 2>/dev/null || : + rm -rf "$WORKDIR" +} +trap at_exit EXIT INT TERM + +# One-shot unit for the guest: verify systemd-detect-virt --cvm matches the type vmspawn launched. +# Created via the systemd.extra-unit.* credential and pulled into the boot by a multi-user.target drop-in. +GUEST_PASS=123 +GUEST_UNIT="$(cat <<'EOF' +[Unit] +Description=coco guest self-check +After=basic.target +SuccessAction=exit +SuccessActionExitStatus=@GUEST_PASS@ +FailureAction=exit +FailureActionExitStatus=1 +[Service] +Type=oneshot +ExecStart=bash -c 'test "$(systemd-detect-virt --cvm)" = "@COCO_TYPE@"' +EOF +)" +GUEST_UNIT="${GUEST_UNIT//@COCO_TYPE@/$COCO_TYPE}" +GUEST_UNIT="${GUEST_UNIT//@GUEST_PASS@/$GUEST_PASS}" + +GUEST_DROPIN="$(cat <<'EOF' +[Unit] +Wants=coco-guest.service +After=coco-guest.service +EOF +)" + +# Launch the confidential guest and capture its console output. +# Direct linux boot should work on all coco platforms (the only boot path supported by SNP). +vmspawn_rc=0 +timeout -k 30 300 systemd-vmspawn \ + --machine="$MACHINE" \ + --coco="$COCO_TYPE" \ + --ram=1G \ + --ephemeral \ + --image="$IMAGE_DIR/image.raw" \ + --linux="$IMAGE_DIR/image.vmlinuz" \ + --initrd="$IMAGE_DIR/image.initrd" \ + --tpm=no \ + --console=read-only \ + --set-credential="systemd.extra-unit.coco-guest.service:$GUEST_UNIT" \ + --set-credential="systemd.unit-dropin.multi-user.target:$GUEST_DROPIN" \ + selinux=0 systemd.firstboot=no rw \ + 2>&1 | tee "$WORKDIR/console.log" || vmspawn_rc="${PIPESTATUS[0]}" +if [[ "$vmspawn_rc" -eq 124 || "$vmspawn_rc" -eq 137 ]]; then + echo "systemd-vmspawn was killed by timeout (exit $vmspawn_rc)" >&2 + cat "$WORKDIR/console.log" >&2 + exit 1 +elif [[ "$vmspawn_rc" -ne "$GUEST_PASS" ]]; then + echo "guest coco self-check did not pass: vmspawn exit $vmspawn_rc (expected $GUEST_PASS)" >&2 + cat "$WORKDIR/console.log" >&2 + exit 1 +fi +echo "Guest correctly reported confidential virtualization: $COCO_TYPE" + +touch /testok